use super::*;
pub(super) const BASE_COLORS: [(&str, &str); 7] = [
(
"neutral",
include_str!("../templates/base-colors/neutral.css"),
),
("stone", include_str!("../templates/base-colors/stone.css")),
("zinc", include_str!("../templates/base-colors/zinc.css")),
("mauve", include_str!("../templates/base-colors/mauve.css")),
("olive", include_str!("../templates/base-colors/olive.css")),
("mist", include_str!("../templates/base-colors/mist.css")),
("taupe", include_str!("../templates/base-colors/taupe.css")),
];
pub(super) const THEME_CSS: &str = "assets/theme.css";
const APP_THEME_HEADER: &str = "//! Generated by `gpuicn init` and `gpuicn theme`";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ThemeConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) base_color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) radius: Option<String>,
#[serde(default)]
pub(super) appearance: AppearanceSetting,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
base_color: Some("neutral".into()),
radius: None,
appearance: AppearanceSetting::System,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub(super) enum AppearanceSetting {
Light,
Dark,
#[default]
System,
}
impl AppearanceSetting {
fn variant(self) -> &'static str {
match self {
Self::Light => "Light",
Self::Dark => "Dark",
Self::System => "System",
}
}
}
pub(super) fn base_color_css(name: &str) -> Result<&'static str> {
BASE_COLORS
.iter()
.find(|(color, _)| *color == name)
.map(|(_, css)| *css)
.with_context(|| {
format!(
"unknown base color {name:?}; one of {}",
BASE_COLORS
.iter()
.map(|(color, _)| *color)
.collect::<Vec<_>>()
.join(", ")
)
})
}
pub(super) fn assets_dir(root: &Path, output: &Path) -> PathBuf {
let output = root.join(output);
output
.parent()
.and_then(Path::parent)
.map_or_else(|| root.to_path_buf(), Path::to_path_buf)
.join("assets")
}
pub(super) fn with_radius(css: &str, radius: &str) -> Result<String> {
ensure!(
css_length_ok(radius),
"radius must be a CSS length in rem or px, such as 0.625rem"
);
let root = css
.find(":root")
.context("theme.css has no `:root` block")?;
let open = root + css[root..].find('{').context("theme.css `:root` block")?;
let close = open + css[open..].find('}').context("theme.css `:root` block")?;
let body = &css[open + 1..close];
let new_body = if let Some(start) = body.find("--radius") {
let end = start + body[start..].find(';').unwrap_or(body.len() - start);
format!("{}--radius: {radius}{}", &body[..start], &body[end..])
} else {
format!("\n --radius: {radius};{body}")
};
Ok(format!("{}{new_body}{}", &css[..=open], &css[close..]))
}
fn css_length_ok(value: &str) -> bool {
let value = value.trim();
let number = value
.strip_suffix("rem")
.or_else(|| value.strip_suffix("px"))
.unwrap_or(value);
number
.trim()
.parse::<f32>()
.is_ok_and(|n| n.is_finite() && n >= 0.)
&& (value.ends_with("rem") || value.ends_with("px") || number.trim() == "0")
}
pub(super) fn css_variables(css: &str, selector: &str) -> BTreeMap<String, String> {
let mut text = String::with_capacity(css.len());
let mut rest = css;
while let Some(start) = rest.find("/*") {
text.push_str(&rest[..start]);
rest = rest[start + 2..]
.split_once("*/")
.map_or("", |(_, after)| after);
}
text.push_str(rest);
let mut variables = BTreeMap::new();
let mut selector_start = 0;
let mut open: Option<(String, usize)> = None;
for (index, character) in text.char_indices() {
match character {
'{' => {
open = Some((text[selector_start..index].trim().to_owned(), index + 1));
selector_start = index + 1;
}
'}' => {
if let Some((name, body)) = open.take()
&& name.split(',').any(|part| part.trim() == selector)
{
for declaration in text[body..index].split(';') {
if let Some((key, value)) = declaration.split_once(':')
&& let Some(key) = key.trim().strip_prefix("--")
{
variables.insert(key.to_owned(), value.trim().to_owned());
}
}
}
selector_start = index + 1;
}
';' if open.is_none() => selector_start = index + 1,
_ => {}
}
}
variables
}
fn required_variables() -> BTreeSet<String> {
css_variables(BASE_COLORS[0].1, ".dark")
.into_keys()
.collect()
}
pub(super) fn check_theme_css(css: &str) -> Result<()> {
let required = required_variables();
for selector in [":root", ".dark"] {
let found = css_variables(css, selector);
ensure!(!found.is_empty(), "no `{selector}` block with variables");
let missing: Vec<_> = required
.iter()
.filter(|name| !found.contains_key(*name))
.map(String::as_str)
.collect();
ensure!(
missing.is_empty(),
"`{selector}` is missing --{}",
missing.join(", --")
);
let radius = found.get("radius");
ensure!(
selector == ".dark" || radius.is_some_and(|r| css_length_ok(r)),
"`:root` needs `--radius` as a length in rem or px"
);
}
Ok(())
}
pub(super) fn theme_css(config: &ThemeConfig, existing: Option<&str>) -> Result<String> {
let css = match &config.base_color {
Some(name) => base_color_css(name)?.to_owned(),
None => existing
.context(format!(
"{THEME_CSS} is missing; run `gpuicn theme set --base-color neutral` or `gpuicn theme import <css>`"
))?
.to_owned(),
};
match &config.radius {
Some(radius) => with_radius(&css, radius),
None => Ok(css),
}
}
pub(super) fn app_theme_source(appearance: AppearanceSetting) -> String {
format!(
"{APP_THEME_HEADER}; change the theme with `gpuicn theme set` or\n\
//! `gpuicn theme import`, or edit `{THEME_CSS}` and keep this file.\n\
\n\
use super::theme::{{Appearance, ThemeMode, UiTheme}};\n\
\n\
/// The app's shadcn theme CSS (`:root` is light, `.dark` is dark).\n\
pub const CSS: &str = include_str!(\"../../{THEME_CSS}\");\n\
/// The appearance gpuicn.toml selects; `System` follows the OS.\n\
pub const APPEARANCE: Appearance = Appearance::{};\n\
\n\
/// The light theme from `{THEME_CSS}`.\n\
pub fn light() -> UiTheme {{\n\
\x20 UiTheme::from_shadcn_css(CSS, ThemeMode::Light).expect(\"{THEME_CSS} parses\")\n\
}}\n\
\n\
/// The dark theme from `{THEME_CSS}`.\n\
pub fn dark() -> UiTheme {{\n\
\x20 UiTheme::from_shadcn_css(CSS, ThemeMode::Dark).expect(\"{THEME_CSS} parses\")\n\
}}\n",
appearance.variant()
)
}
pub(super) fn write_theme_files(root: &Path, config: &Config, replace: bool) -> Result<()> {
let css_path = assets_dir(root, &config.output).join("theme.css");
let existing = current_file(&css_path)?
.map(|bytes| String::from_utf8(bytes).context("theme.css is not UTF-8"))
.transpose()?;
let css = theme_css(&config.theme, existing.as_deref())?;
check_theme_css(&css).context("theme.css")?;
match &existing {
None => write_new(&css_path, css.as_bytes())?,
Some(current) if *current == css => {}
Some(_) if replace => {
fs::write(&css_path, &css)?;
println!("Write {}", css_path.display());
}
Some(_) => println!(
"Keep {} (edited; `gpuicn theme set` regenerates it)",
css_path.display()
),
}
let output = root.join(&config.output);
let source = app_theme_source(config.theme.appearance);
let path = output.join("app_theme.rs");
let current = current_file(&path)?;
if current.as_deref() != Some(source.as_bytes()) {
if current
.as_deref()
.is_some_and(|text| !text.starts_with(APP_THEME_HEADER.as_bytes()))
{
println!("Keep {} (not generated by gpuicn)", path.display());
} else {
fs::create_dir_all(&output)?;
fs::write(&path, &source)?;
println!("Write {}", path.display());
}
}
let mod_path = output.join("mod.rs");
let existing_mod = String::from_utf8(current_file(&mod_path)?.unwrap_or_default())?;
let updated_mod = declarations(&existing_mod, ["app_theme".to_owned()].into_iter())?;
if updated_mod != existing_mod {
fs::write(&mod_path, &updated_mod)?;
println!("Write {}", mod_path.display());
}
Ok(())
}
pub(super) fn read_css(location: &str, root: &Path) -> Result<String> {
let bytes = if location.starts_with("https://") || location.starts_with("http://") {
download(&http_client()?, location, MAX_ITEM_BYTES)
.with_context(|| format!("fetch {location}"))?
} else {
fs::read(root.join(location)).with_context(|| format!("read {location}"))?
};
String::from_utf8(bytes).context("theme CSS is not UTF-8")
}
pub(super) fn import(
root: &Path,
config_path: &Path,
mut config: Config,
source: &str,
) -> Result<()> {
let css = read_css(source, root)?;
check_theme_css(&css).with_context(|| format!("{source} is not a complete shadcn theme"))?;
let css_path = assets_dir(root, &config.output).join("theme.css");
fs::create_dir_all(css_path.parent().context("assets directory")?)?;
fs::write(&css_path, &css)?;
println!("Write {} (from {source})", css_path.display());
config.theme.base_color = None;
config.theme.radius = None;
write_config(config_path, &config)?;
write_theme_files(root, &config, true)?;
println!("\nThe app now uses the imported theme. `gpuicn theme preview` prints its tokens.");
Ok(())
}
pub(super) fn set(
root: &Path,
config_path: &Path,
mut config: Config,
base_color: Option<String>,
radius: Option<String>,
appearance: Option<AppearanceSetting>,
) -> Result<()> {
ensure!(
base_color.is_some() || radius.is_some() || appearance.is_some(),
"pass --base-color, --radius or --appearance"
);
if let Some(name) = base_color {
base_color_css(&name)?;
config.theme.base_color = Some(name);
}
if let Some(radius) = radius {
ensure!(
css_length_ok(&radius),
"radius must be a CSS length in rem or px, such as 0.625rem"
);
config.theme.radius = Some(radius);
}
if let Some(appearance) = appearance {
config.theme.appearance = appearance;
}
write_config(config_path, &config)?;
write_theme_files(root, &config, true)
}
pub(super) fn preview(root: &Path, config: &Config) -> Result<()> {
let css_path = assets_dir(root, &config.output).join("theme.css");
let css = match current_file(&css_path)? {
Some(bytes) => String::from_utf8(bytes)?,
None => theme_css(&config.theme, None)?,
};
let light = css_variables(&css, ":root");
let dark = css_variables(&css, ".dark");
println!(
"{} · appearance {}\n",
config.theme.base_color.as_deref().map_or_else(
|| format!("imported ({})", css_path.display()),
|c| format!("base color {c}")
),
toml::Value::try_from(config.theme.appearance)?
.as_str()
.unwrap_or("system")
);
let width = light.keys().map(String::len).max().unwrap_or(0);
println!("{:width$} {:<28} dark", "token", "light");
for (name, value) in &light {
println!(
"{name:width$} {value:<28} {}",
dark.get(name).map_or("(light)", String::as_str)
);
}
Ok(())
}
pub(super) fn write_config(path: &Path, config: &Config) -> Result<()> {
let mut config = config.clone();
config.version = 2;
let text = toml::to_string_pretty(&config)?;
if current_file(path)?.as_deref() != Some(text.as_bytes()) {
fs::write(path, text)?;
println!("Write {}", path.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_colors_are_complete_and_radius_is_replaced() -> Result<()> {
for (name, css) in BASE_COLORS {
check_theme_css(css).with_context(|| name.to_owned())?;
let rounded = with_radius(css, "0.5rem")?;
assert_eq!(
css_variables(&rounded, ":root")["radius"],
"0.5rem",
"{name}"
);
assert_eq!(
css_variables(&rounded, ":root").len(),
css_variables(css, ":root").len()
);
}
assert!(with_radius(BASE_COLORS[0].1, "big").is_err());
let no_radius =
":root {\n --background: oklch(1 0 0);\n}\n.dark { --background: oklch(0 0 0); }";
assert_eq!(
css_variables(&with_radius(no_radius, "4px")?, ":root")["radius"],
"4px"
);
assert!(check_theme_css(no_radius).is_err());
Ok(())
}
#[test]
fn theme_files_follow_the_config_and_keep_edits() -> Result<()> {
let temp = tempfile::tempdir()?;
let root = temp.path();
let config = Config {
version: 2,
registry: "r".into(),
output: "src/ui".into(),
mode: Mode::Source,
theme: ThemeConfig {
base_color: Some("zinc".into()),
radius: Some("0.5rem".into()),
appearance: AppearanceSetting::Dark,
},
};
write_theme_files(root, &config, false)?;
let css = fs::read_to_string(root.join("assets/theme.css"))?;
assert_eq!(css, with_radius(base_color_css("zinc")?, "0.5rem")?);
let generated = fs::read_to_string(root.join("src/ui/app_theme.rs"))?;
assert!(generated.contains("include_str!(\"../../assets/theme.css\")"));
assert!(generated.contains("Appearance::Dark"));
assert_eq!(
fs::read_to_string(root.join("src/ui/mod.rs"))?,
"pub mod app_theme;\n"
);
syn::parse_file(&generated)?;
fs::write(
root.join("assets/theme.css"),
"/* mine */\n".to_owned() + &css,
)?;
write_theme_files(root, &config, false)?;
assert!(fs::read_to_string(root.join("assets/theme.css"))?.starts_with("/* mine */"));
write_theme_files(root, &config, true)?;
assert_eq!(fs::read_to_string(root.join("assets/theme.css"))?, css);
let deep = Config {
output: "src/components/ui".into(),
..config.clone()
};
assert_eq!(assets_dir(root, &deep.output), root.join("src/assets"));
let imported = base_color_css("stone")?.replace("0.625rem", "1rem");
fs::write(root.join("import.css"), &imported)?;
import(
root,
&root.join("gpuicn.toml"),
config.clone(),
"import.css",
)?;
assert_eq!(fs::read_to_string(root.join("assets/theme.css"))?, imported);
let saved: Config = toml::from_str(&fs::read_to_string(root.join("gpuicn.toml"))?)?;
assert_eq!(saved.version, 2);
assert_eq!(saved.theme.base_color, None);
assert_eq!(saved.theme.radius, None);
assert_eq!(saved.theme.appearance, AppearanceSetting::Dark);
set(
root,
&root.join("gpuicn.toml"),
saved,
None,
Some("2px".into()),
Some(AppearanceSetting::Light),
)?;
assert_eq!(
css_variables(&fs::read_to_string(root.join("assets/theme.css"))?, ":root")["radius"],
"2px"
);
assert!(
fs::read_to_string(root.join("src/ui/app_theme.rs"))?.contains("Appearance::Light")
);
assert!(fs::write(root.join("bad.css"), ":root { --a: b; }").is_ok());
assert!(import(root, &root.join("gpuicn.toml"), config, "bad.css").is_err());
Ok(())
}
}