use std::collections::BTreeSet;
use std::env;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
const FIELD_MAP: &[(&str, &[&str])] = &[
("background", &["background"]),
("foreground", &["foreground"]),
("border", &["border"]),
("ring", &["ring"]),
("selection_background", &["selection.background"]),
("primary_background", &["primary.background"]),
("primary_foreground", &["primary.foreground"]),
("primary_hover_background", &["primary.hover.background"]),
("primary_active_background", &["primary.active.background"]),
("secondary_background", &["secondary.background"]),
("secondary_foreground", &["secondary.foreground"]),
("secondary_hover_background", &["secondary.hover.background"]),
("secondary_active_background", &["secondary.active.background"]),
("accent_background", &["accent.background"]),
("accent_foreground", &["accent.foreground"]),
("muted_background", &["muted.background"]),
("muted_foreground", &["muted.foreground"]),
("danger_background", &["danger.background"]),
("danger_foreground", &["danger.foreground"]),
("success_background", &["success.background"]),
("success_foreground", &["success.foreground"]),
("warning_background", &["warning.background"]),
("warning_foreground", &["warning.foreground"]),
("info_background", &["info.background"]),
("info_foreground", &["info.foreground"]),
("input_border", &["input.border"]),
("popover_background", &["popover.background"]),
("popover_foreground", &["popover.foreground"]),
("slider_bar_background", &["slider.background", "slider.bar.background"]),
("slider_thumb_background", &["slider.thumb.background"]),
("switch_background", &["switch.background"]),
("link_foreground", &["link.foreground", "link"]),
("link_hover_foreground", &["link.hover.foreground", "link.hover"]),
("link_active_foreground", &["link.active.foreground", "link.active"]),
];
fn main() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let themes_dir = manifest_dir.join("themes");
println!("cargo:rerun-if-changed=themes");
println!("cargo:rerun-if-changed=build.rs");
let mut files: Vec<PathBuf> = fs::read_dir(&themes_dir)
.unwrap_or_else(|e| panic!("read_dir {}: {e}", themes_dir.display()))
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
files.sort();
let mut out = String::new();
out.push_str("// @generated by build.rs from themes/*.json — do not edit.\n\n");
let mut listing: Vec<(String, String, String)> = Vec::new();
let mut used_idents: BTreeSet<String> = BTreeSet::new();
for path in &files {
let content = fs::read_to_string(path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let value: Value = serde_json::from_str(&content)
.unwrap_or_else(|e| panic!("parse {}: {e}", path.display(), ));
let themes = value
.get("themes")
.and_then(|t| t.as_array())
.unwrap_or_else(|| panic!("{}: missing `themes` array", path.display()));
let family = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
for entry in themes {
emit_theme(&mut out, &mut listing, &mut used_idents, path, &family, entry);
}
}
out.push_str("/// Every bundled theme, in deterministic order.\n");
out.push_str("pub const ALL: &[Preset] = &[\n");
for (display_name, family, ident) in &listing {
writeln!(
out,
" Preset {{ name: {}, family: {}, theme: {} }},",
quote_str(display_name),
quote_str(family),
ident
)
.unwrap();
}
out.push_str("];\n\n");
out.push_str("/// Look up a bundled theme by its display name (case-sensitive, exact match).\n");
out.push_str("pub fn by_name(name: &str) -> Option<Theme> {\n");
out.push_str(" ALL.iter().find_map(|p| (p.name == name).then_some(p.theme))\n");
out.push_str("}\n");
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
let dest = out_dir.join("presets_generated.rs");
fs::write(&dest, out).unwrap_or_else(|e| panic!("write {}: {e}", dest.display()));
}
fn emit_theme(
out: &mut String,
listing: &mut Vec<(String, String, String)>,
used_idents: &mut BTreeSet<String>,
path: &Path,
family: &str,
entry: &Value,
) {
let name = entry
.get("name")
.and_then(|n| n.as_str())
.unwrap_or_else(|| panic!("{}: theme entry missing `name`", path.display()));
let mode = entry
.get("mode")
.and_then(|m| m.as_str())
.unwrap_or_else(|| panic!("{}: theme `{name}` missing `mode`", path.display()));
let (mode_variant, base_fn) = match mode {
"light" => ("Light", "ThemeColor::light()"),
"dark" => ("Dark", "ThemeColor::dark()"),
other => panic!("{}: theme `{name}` has unknown mode `{other}`", path.display()),
};
let mut ident = make_ident(name);
if !used_idents.insert(ident.clone()) {
let mut n = 2usize;
loop {
let candidate = format!("{ident}_{n}");
if used_idents.insert(candidate.clone()) {
ident = candidate;
break;
}
n += 1;
}
}
let empty = serde_json::Map::new();
let colors = entry
.get("colors")
.and_then(|c| c.as_object())
.unwrap_or(&empty);
writeln!(out, "/// Generated from `themes/{}` — theme \"{name}\".", path.file_name().unwrap().to_string_lossy()).unwrap();
writeln!(out, "pub const {ident}: Theme = Theme {{").unwrap();
writeln!(out, " mode: ThemeMode::{mode_variant},").unwrap();
writeln!(out, " metrics: Theme::light().metrics,").unwrap();
writeln!(out, " colors: ThemeColor {{").unwrap();
for (field, keys) in FIELD_MAP {
let mut emitted = false;
for key in *keys {
let Some(raw) = colors.get(*key).and_then(|v| v.as_str()) else { continue };
let Some((r, g, b, a)) = parse_color(raw) else {
println!(
"cargo:warning=theme {name}: invalid color `{raw}` for `{key}` — skipping",
);
continue;
};
if a == 0xff {
writeln!(
out,
" {field}: Color32::from_rgb(0x{:02x}, 0x{:02x}, 0x{:02x}),",
r, g, b
)
.unwrap();
} else {
writeln!(
out,
" {field}: Color32::from_rgba_premultiplied(0x{:02x}, 0x{:02x}, 0x{:02x}, 0x{:02x}),",
r, g, b, a
)
.unwrap();
}
emitted = true;
break;
}
let _ = emitted;
}
writeln!(out, " ..{base_fn}").unwrap();
writeln!(out, " }},").unwrap();
writeln!(out, "}};\n").unwrap();
listing.push((name.to_string(), family.to_string(), ident));
}
fn make_ident(name: &str) -> String {
let mut s = String::new();
let mut prev_underscore = true;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
s.push(ch.to_ascii_uppercase());
prev_underscore = false;
} else if !prev_underscore {
s.push('_');
prev_underscore = true;
}
}
let s = s.trim_end_matches('_').to_string();
if s.is_empty() || s.chars().next().unwrap().is_ascii_digit() {
format!("THEME_{s}")
} else {
s
}
}
fn parse_color(s: &str) -> Option<(u8, u8, u8, u8)> {
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
let bytes = s.as_bytes();
let h = |i: usize| {
let c = bytes.get(i)?;
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
};
match bytes.len() {
3 => {
let r = h(0)?;
let g = h(1)?;
let b = h(2)?;
Some((r * 0x11, g * 0x11, b * 0x11, 0xff))
}
4 => {
let r = h(0)?;
let g = h(1)?;
let b = h(2)?;
let a = h(3)?;
Some((r * 0x11, g * 0x11, b * 0x11, a * 0x11))
}
6 => {
let r = h(0)? * 16 + h(1)?;
let g = h(2)? * 16 + h(3)?;
let b = h(4)? * 16 + h(5)?;
Some((r, g, b, 0xff))
}
8 => {
let r = h(0)? * 16 + h(1)?;
let g = h(2)? * 16 + h(3)?;
let b = h(4)? * 16 + h(5)?;
let a = h(6)? * 16 + h(7)?;
Some((r, g, b, a))
}
_ => None,
}
}
fn quote_str(s: &str) -> String {
let mut out = String::from("\"");
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => write!(out, "\\u{{{:x}}}", c as u32).unwrap(),
c => out.push(c),
}
}
out.push('"');
out
}