egui-components-theme 0.1.0

Theme tokens for egui, ported from longbridge/gpui-component
Documentation
//! Compile-time theme generator.
//!
//! Reads every `themes/*.json` file (vendored from
//! [longbridge/gpui-component](https://github.com/longbridge/gpui-component/tree/main/themes))
//! and emits Rust constants of type `Theme` for each theme defined inside.
//! Generated code is written to `$OUT_DIR/presets_generated.rs` and re-included
//! from `src/presets.rs`.

use std::collections::BTreeSet;
use std::env;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};

use serde_json::Value;

/// Mapping from the local `ThemeColor` field name to the JSON keys that
/// should populate it, tried in order. The first key present in the JSON
/// wins; if none are present the field falls back to the mode's default
/// via struct-update syntax (`..ThemeColor::light()` / `::dark()`).
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
}