use std::path::PathBuf;
use std::sync::OnceLock;
use anyhow::{Result, bail};
pub const DEFAULT: &str = "auto";
const AUTO_LIGHT: &str = "default-light";
const AUTO_DARK: &str = "default-dark";
const SCHEMES: &[(&str, &str)] = &[
(
"default-light",
include_str!("../../themes/default-light.yaml"),
),
(
"default-dark",
include_str!("../../themes/default-dark.yaml"),
),
("github", include_str!("../../themes/github.yaml")),
("github-dark", include_str!("../../themes/github-dark.yaml")),
(
"catppuccin-latte",
include_str!("../../themes/catppuccin-latte.yaml"),
),
(
"catppuccin-mocha",
include_str!("../../themes/catppuccin-mocha.yaml"),
),
(
"gruvbox-light-hard",
include_str!("../../themes/gruvbox-light-hard.yaml"),
),
(
"gruvbox-dark-hard",
include_str!("../../themes/gruvbox-dark-hard.yaml"),
),
(
"solarized-light",
include_str!("../../themes/solarized-light.yaml"),
),
(
"solarized-dark",
include_str!("../../themes/solarized-dark.yaml"),
),
(
"rose-pine-dawn",
include_str!("../../themes/rose-pine-dawn.yaml"),
),
("rose-pine", include_str!("../../themes/rose-pine.yaml")),
("one-light", include_str!("../../themes/one-light.yaml")),
("onedark", include_str!("../../themes/onedark.yaml")),
("nord", include_str!("../../themes/nord.yaml")),
(
"tokyo-night-dark",
include_str!("../../themes/tokyo-night-dark.yaml"),
),
("dracula", include_str!("../../themes/dracula.yaml")),
];
pub struct Theme {
pub name: String,
pub label: String,
pub variant: &'static str,
vars: String,
}
fn themes() -> &'static [Theme] {
static PARSED: OnceLock<Vec<Theme>> = OnceLock::new();
PARSED.get_or_init(|| {
let mut out = vec![Theme {
name: DEFAULT.to_string(),
label: "Follow the system".to_string(),
variant: "system",
vars: String::new(),
}];
for (name, text) in SCHEMES {
if let Some(scheme) = Scheme::parse(text) {
let vars = scheme.vars();
out.push(Theme {
name: (*name).to_string(),
label: scheme.label,
variant: if scheme.dark { "dark" } else { "light" },
vars,
});
}
}
out
})
}
pub fn all() -> &'static [Theme] {
themes()
}
pub fn exists(name: &str) -> bool {
themes().iter().any(|t| t.name == name)
}
pub fn check(name: &str) -> Result<()> {
if exists(name) {
return Ok(());
}
let known: Vec<&str> = themes().iter().map(|t| t.name.as_str()).collect();
bail!("no theme called {name:?}; try one of: {}", known.join(", "))
}
pub fn css_for(name: &str) -> String {
let found = themes().iter().find(|t| t.name == name);
match found {
Some(t) if t.variant != "system" => format!(":root{{{}}}", t.vars),
_ => {
let light = vars_named(AUTO_LIGHT);
let dark = vars_named(AUTO_DARK);
format!(":root{{{light}}}@media(prefers-color-scheme:dark){{:root{{{dark}}}}}")
}
}
}
fn vars_named(name: &str) -> &'static str {
themes()
.iter()
.find(|t| t.name == name)
.map_or("", |t| t.vars.as_str())
}
struct Scheme {
label: String,
dark: bool,
palette: [String; 16],
}
impl Scheme {
fn parse(text: &str) -> Option<Self> {
let mut label = None;
let mut variant = None;
let mut palette: [Option<String>; 16] = [const { None }; 16];
for line in text.lines() {
let Some((key, value)) = field(line) else {
continue;
};
match key {
"name" => label = Some(value),
"variant" => variant = Some(value),
_ => {
if let Some(slot) = base_index(key) {
palette[slot] = Some(value);
}
}
}
}
let mut colours: Vec<String> = Vec::with_capacity(16);
for slot in palette {
colours.push(slot?);
}
Some(Self {
label: label?,
dark: variant.as_deref() != Some("light"),
palette: colours.try_into().ok()?,
})
}
fn vars(&self) -> String {
let c = |i: usize| self.palette[i].as_str();
[
format!("--bg:{}", c(0x0)),
format!("--hover:{}", c(0x1)),
format!("--line:{}", c(0x1)),
format!("--sel:{}", c(0x2)),
format!("--faint:{}", c(0x3)),
format!("--dim:{}", c(0x4)),
format!("--fg:{}", c(0x5)),
format!("--accent:{}", c(0xD)),
format!("--k-page:{}", c(0x9)),
format!("--k-doc:{}", c(0xD)),
format!("--k-data:{}", c(0xA)),
format!("--k-code:{}", c(0xE)),
format!("--k-media:{}", c(0xB)),
format!("--k-plain:{}", c(0x3)),
]
.join(";")
}
}
fn field(line: &str) -> Option<(&str, String)> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let (key, rest) = line.split_once(':')?;
let rest = rest.trim();
let value = match rest.strip_prefix('"') {
Some(quoted) => quoted.split('"').next()?,
None => rest.split('#').next()?.trim(),
};
(!value.is_empty()).then(|| (key.trim(), value.to_string()))
}
fn base_index(key: &str) -> Option<usize> {
let digits = key.strip_prefix("base")?;
(digits.len() == 2)
.then(|| usize::from_str_radix(digits, 16).ok())
.flatten()
.filter(|slot| *slot < 16)
}
fn stored_path() -> Option<PathBuf> {
Some(crate::control::state_dir()?.join("theme"))
}
pub fn remembered() -> Option<String> {
let name = std::fs::read_to_string(stored_path()?).ok()?;
let name = name.trim().to_string();
exists(&name).then_some(name)
}
pub fn remember(name: &str) -> Result<PathBuf> {
check(name)?;
let path = match stored_path() {
Some(path) => path,
None => bail!("no directory to remember a theme in"),
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, format!("{name}\n"))?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_vendored_scheme_parses() {
for (name, text) in SCHEMES {
let scheme = Scheme::parse(text).unwrap_or_else(|| panic!("{name} did not parse"));
assert!(!scheme.label.is_empty(), "{name} has no label");
for (slot, colour) in scheme.palette.iter().enumerate() {
assert!(
colour.starts_with('#') && colour.len() == 7,
"{name} base{slot:02X} is {colour:?}, which is not a hex colour"
);
}
}
assert_eq!(
themes().len(),
SCHEMES.len() + 1,
"one of the vendored schemes was dropped, plus auto"
);
}
#[test]
fn every_theme_sets_every_variable() {
let wanted = [
"--bg",
"--fg",
"--dim",
"--faint",
"--line",
"--hover",
"--sel",
"--accent",
"--k-page",
"--k-doc",
"--k-data",
"--k-code",
"--k-media",
"--k-plain",
];
for theme in all() {
let css = css_for(&theme.name);
for var in wanted {
assert!(
css.contains(&format!("{var}:")),
"{} is missing {var}",
theme.name
);
}
}
}
#[test]
fn the_curated_set_has_light_and_dark() {
let light = all().iter().filter(|t| t.variant == "light").count();
let dark = all().iter().filter(|t| t.variant == "dark").count();
assert!(light >= 6, "only {light} light schemes");
assert!(dark >= 6, "only {dark} dark schemes");
}
#[test]
fn the_default_carries_a_light_and_a_dark_palette() {
let css = css_for(DEFAULT);
assert!(css.contains("prefers-color-scheme:dark"), "{css}");
assert!(css.contains(vars_named(AUTO_LIGHT)), "{css}");
assert!(css.contains(vars_named(AUTO_DARK)), "{css}");
}
#[test]
fn a_fixed_theme_does_not_follow_the_system() {
let css = css_for("gruvbox-dark-hard");
assert!(!css.contains("prefers-color-scheme"), "{css}");
assert!(css.contains("--bg:#1d2021"), "{css}");
assert!(css.contains("--fg:#d5c4a1"), "{css}");
}
#[test]
fn a_light_scheme_maps_the_same_way_round() {
let css = css_for("gruvbox-light-hard");
assert!(
css.contains("--bg:#f9f5d7"),
"the background is base00: {css}"
);
assert!(
css.contains("--fg:#504945"),
"the foreground is base05: {css}"
);
}
#[test]
fn an_unknown_theme_renders_as_the_default_rather_than_as_nothing() {
assert_eq!(css_for("no-such-theme"), css_for(DEFAULT));
}
#[test]
fn an_unknown_theme_is_refused_where_it_is_configured() {
let e = check("no-such-theme").expect_err("should be refused");
let said = format!("{e}");
assert!(said.contains("no-such-theme"), "{said}");
assert!(
said.contains("nord"),
"the error should list the themes: {said}"
);
}
#[test]
fn the_default_is_a_theme_that_exists() {
assert!(exists(DEFAULT));
check(DEFAULT).expect("the default must be valid");
}
#[test]
fn theme_names_are_unique() {
let mut names: Vec<&str> = all().iter().map(|t| t.name.as_str()).collect();
names.sort_unstable();
let before = names.len();
names.dedup();
assert_eq!(names.len(), before, "two themes share a name");
}
#[test]
fn the_parser_reads_the_shapes_these_files_come_in() {
assert_eq!(
field(r##" base00: "#1d2021" # ----"##),
Some(("base00", "#1d2021".to_string()))
);
assert_eq!(
field(r#"name: "Gruvbox dark, hard""#),
Some(("name", "Gruvbox dark, hard".to_string()))
);
assert_eq!(field("# a whole-line comment"), None);
assert_eq!(field(""), None);
assert_eq!(field("palette:"), None);
}
#[test]
fn base_slots_are_read_as_hex() {
assert_eq!(base_index("base00"), Some(0));
assert_eq!(base_index("base0F"), Some(15));
assert_eq!(base_index("base0f"), Some(15));
assert_eq!(base_index("base10"), None);
assert_eq!(base_index("name"), None);
assert_eq!(base_index("base0"), None);
}
#[test]
fn a_scheme_missing_a_colour_is_not_a_scheme() {
let short = "system: \"base16\"\nname: \"Short\"\nvariant: \"dark\"\npalette:\n base00: \"#000000\"\n";
assert!(Scheme::parse(short).is_none());
}
}