4.6 KiB · text History 0fc5cce
#!/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 for the Plasma wallpaper and
droid/app/src/main/java/io/neiam/moire/Themes.kt for the Android one.
Both are 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"
OUT_KT = ROOT / "droid/app/src/main/java/io/neiam/moire/Themes.kt"

# 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 render_kotlin(themes, source):
    out = [
        f"// Generated by tools/themes.py from waxx's {source}; do not edit.",
        "package io.neiam.moire",
        "",
        "/** A colour as OKLCH: lightness 0-1, chroma, hue in degrees. */",
        "data class Lch(val l: Double, val c: Double, val h: Double)",
        "",
        "/** The wallpaper's four roles; see tools/themes.py for their sources. */",
        "data class Theme(val base: Lch, val deep: Lch, val ink: Lch, val tone: Lch)",
        "",
        "object Themes {",
        "    val all: Map<String, Theme> = linkedMapOf(",
    ]
    for name, roles in themes:
        out.append(f'        "{name}" to Theme(')
        for role, (l, c, h) in roles.items():
            out.append(f"            {role} = Lch({l:.4f}, {c:.3f}, {h:.3f}),  // {oklch_to_srgb(l, c, h)}")
        out.append("        ),")
    out += ["    )", "", "    val names: List<String> = all.keys.toList()", "}", ""]
    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))
    OUT_KT.parent.mkdir(parents=True, exist_ok=True)
    OUT_KT.write_text(render_kotlin(themes, css.name))
    print(f"wrote {OUT.relative_to(ROOT)} and {OUT_KT.relative_to(ROOT)}: {', '.join(n for n, _ in themes)}")


if __name__ == "__main__":
    main()