3.3 KiB · text History f629a67
#!/usr/bin/env python3
"""Port the daisyUI themes from waxx's app.css into the moire wallpaper.

    tools/themes.py ../waxx/assets/css/app.css

writes moire/theme/contents/ui/themes.js. The file is committed, so
building needs neither waxx nor this script; re-run it when waxx's
palette changes.

Colours are kept in OKLCH, as the CSS defines them, so the wallpaper
can blend between themes by turning the hue rather than mixing sRGB,
where a crossfade between two saturated colours goes muddy halfway.
The sRGB hex beside each is for people reading the file.
"""

import math
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "moire/theme/contents/ui/themes.js"

# The wallpaper's four roles, and the daisyUI variable each is taken from.
ROLES = {
    "base": "--color-base-100",     # the ground
    "deep": "--color-base-300",     # the ground at the edges
    "ink": "--color-accent",        # the moire
    "tone": "--color-primary",      # the halftone
}

BLOCK = re.compile(r'@plugin\s+"[^"]*daisyui-theme"\s*\{(.*?)\}', re.S)
NAME = re.compile(r'name:\s*"([^"]+)"')
VAR = re.compile(r"(--color-[a-z0-9-]+):\s*oklch\(([\d.]+)%\s+([\d.]+)\s+([\d.]+)\)")


def oklch_to_srgb(l, c, h):
    a = c * math.cos(math.radians(h))
    b = c * math.sin(math.radians(h))
    l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3
    m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3
    s_ = (l - 0.0894841775 * a - 1.2914855480 * b) ** 3
    rgb = (
        4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
        -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
        -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_,
    )

    def encode(x):
        x = min(max(x, 0.0), 1.0)
        return 12.92 * x if x <= 0.0031308 else 1.055 * x ** (1 / 2.4) - 0.055

    return "#" + "".join(f"{round(encode(x) * 255):02x}" for x in rgb)


def parse(css):
    themes = []
    for block in BLOCK.findall(css):
        name = NAME.search(block)
        if not name:
            continue
        colours = {m[0]: (float(m[1]) / 100, float(m[2]), float(m[3])) for m in VAR.findall(block)}
        missing = [v for v in ROLES.values() if v not in colours]
        if missing:
            sys.exit(f"theme {name[1]}: missing {', '.join(missing)}")
        themes.append((name[1], {role: colours[var] for role, var in ROLES.items()}))
    return themes


def render(themes, source):
    out = [
        f"// Generated by tools/themes.py from waxx's {source}; do not edit.",
        "// OKLCH as [lightness 0-1, chroma, hue degrees]; hex is for reading.",
        ".pragma library",
        "",
        "var themes = {",
    ]
    for name, roles in themes:
        out.append(f'    "{name}": {{')
        for role, (l, c, h) in roles.items():
            out.append(f"        {role}: [{l:.4f}, {c:.3f}, {h:.3f}],  // {oklch_to_srgb(l, c, h)}")
        out.append("    },")
    out += ["}", "", "var names = Object.keys(themes)", ""]
    return "\n".join(out)


def main():
    if len(sys.argv) != 2:
        sys.exit(__doc__.strip().splitlines()[2].strip())
    css = Path(sys.argv[1])
    themes = parse(css.read_text())
    OUT.parent.mkdir(parents=True, exist_ok=True)
    OUT.write_text(render(themes, css.name))
    print(f"wrote {OUT.relative_to(ROOT)}: {', '.join(n for n, _ in themes)}")


if __name__ == "__main__":
    main()