diff options
Diffstat (limited to 'bin/udt-palette')
| -rwxr-xr-x | bin/udt-palette | 416 |
1 files changed, 416 insertions, 0 deletions
diff --git a/bin/udt-palette b/bin/udt-palette new file mode 100755 index 0000000..b2951c6 --- /dev/null +++ b/bin/udt-palette @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +# udt-palette: generate every themed config from one palette and one role map. +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# Licensed under the GNU General Public License v2 only. +"""Render the palette into each consumer's own syntax. + +Usage: udt-palette [--roles <file>] [--out <dir>] + udt-palette --selftest + +The point of this script is that a colour is written down once. palette/<scheme> +.conf holds the colours under the scheme's own names, palette/roles.conf says +what each is for, and everything else here is generated. Editing a generated +file is pointless: the next install.sh overwrites it. +""" + +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +PALETTE_DIR = REPO / "palette" + + +def parse_conf(path): + """Parse `key = value` lines into a dict, preserving order. + + [section] headers are read but not nested: roles are a flat namespace, and + the sections exist to group the file for a human reader. A duplicate key is + an error rather than a silent last-wins, because two roles quietly fighting + is exactly the drift this file exists to prevent. + """ + out = {} + for lineno, raw in enumerate(path.read_text().splitlines(), 1): + # A comment is a whole line starting with #, or a trailing "# " after + # a value. Splitting on a bare "#" would eat every colour, since the + # values are hex literals that start with one. + line = raw.strip() + if line.startswith("#"): + continue + line = re.split(r"\s#\s", line, maxsplit=1)[0].strip() + if not line or line.startswith("["): + continue + if "=" not in line: + raise ValueError(f"{path}:{lineno}: expected 'key = value', got {raw!r}") + key, value = (p.strip() for p in line.split("=", 1)) + if key in out: + raise ValueError(f"{path}:{lineno}: duplicate key {key!r}") + out[key] = value + return out + + +def resolve(roles, palette, roles_path): + """Turn every role into (r, g, b, alpha). Unknown colour names are fatal. + + A role names a palette colour, optionally with an alpha percentage: + `text/75`. Failing here is the whole safety story: a typo or a name the new + scheme does not carry stops the build, rather than emitting a config with a + black hole where a colour should be. + """ + resolved = {} + for role, spec in roles.items(): + if role in ("scheme", "snap"): + continue + # `name/75` is 75% alpha; `name*50` is the colour at 50% brightness. + # shade exists because GTK's shade() has no palette name to point at: + # half-brightness crust is darker than the darkest colour a scheme + # ships, so it can only be computed. + rest, _, alpha_s = spec.partition("/") + name, _, shade_s = rest.partition("*") + name = name.strip() + if name not in palette: + available = ", ".join(sorted(palette)) + raise ValueError( + f"{roles_path}: role {role!r} wants colour {name!r}, which the " + f"{roles['scheme']} palette does not define.\nAvailable: {available}") + alpha = 100 + if alpha_s: + alpha = int(alpha_s.strip()) + if not 0 <= alpha <= 100: + raise ValueError(f"{roles_path}: role {role!r} alpha {alpha} out of 0-100") + hexval = palette[name] + r, g, b = (int(hexval[i:i + 2], 16) for i in (1, 3, 5)) + if shade_s: + factor = int(shade_s.strip()) / 100 + if not 0 <= factor <= 2: + raise ValueError(f"{roles_path}: role {role!r} shade out of 0-200") + r, g, b = (min(255, round(c * factor)) for c in (r, g, b)) + resolved[role] = (r, g, b, alpha) + return resolved + + +def hex6(c): + r, g, b, _ = c + return f"#{r:02x}{g:02x}{b:02x}" + + +def hex8(c): + r, g, b, a = c + return f"#{r:02x}{g:02x}{b:02x}{round(a * 255 / 100):02x}" + + +def rgba(c): + r, g, b, a = c + return f"rgb({r},{g},{b})" if a == 100 else f"rgba({r},{g},{b},{a / 100:.2f})" + + +BANNER = "Generated by udt-palette from palette/{scheme}.conf. Do not edit." + + +def gen_rofi(res, scheme, palette): + """rofi: the structural palette, under the scheme's own colour names. + + Emits palette names rather than roles because the rofi themes were written + against Catppuccin names and still use them. The role layer reaches rofi + through accent.rasi, which udt-accent generates per wallpaper. + """ + rows = "\n".join(f" {k + ':':11}{v}ff;" for k, v in sorted(palette.items())) + return ( + f"/*\n * {BANNER.format(scheme=scheme)}\n *\n" + " * Structural colors only. The accent lives in accent.rasi.\n */\n\n" + f"* {{\n{rows}\n}}\n" + ) + + +def gen_waybar(res, scheme, palette): + """waybar: one file holding the palette and the role layer. + + Both halves together because style.css imports a single themes/<scheme>.css, + and the stylesheets reference names from each: @lavender and @surface1 from + the palette, @cpu and @hover-bg from the roles. + + Role names keep their existing hyphenated spelling (main-bg, not main_bg): + the stylesheets reference them and are not generated. + """ + palette_rows = "\n".join(f"@define-color {k:<12}{v};" + for k, v in sorted(palette.items())) + + def emit(role, css_name=None): + c = res[role] + return f"@define-color {(css_name or role.replace('_', '-')):<12}{rgba(c)};" + + ui = ["main_br", "main_bg", "main_fg", "hover_bg", "hover_fg", "outline"] + modules = ["workspaces", "temperature", "memory", "cpu", "time", "date", + "tray", "volume", "backlight", "battery"] + states = ["warning", "critical", "charging"] + + return "\n".join([ + f"/* {BANNER.format(scheme=scheme)} */", + "", + palette_rows, + "", + "/* br - border, bg - background, fg - foreground */", + "", + "/* main colors */", + f"@define-color accent {rgba(res['accent'])};", + *(emit(r) for r in ui), + "", + "/* module colors */", + *(emit(r) for r in modules), + "", + "/* state colors */", + *(emit(r) for r in states), + "", + ]) + + +def gen_kitty(res, scheme, palette): + """kitty: the 16 ANSI slots plus chrome. + + kitty has no include-with-override, so this is the whole colour section of + the theme file rather than a fragment. + """ + ansi = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"] + lines = [f"# {BANNER.format(scheme=scheme)}", ""] + for key, role in [("foreground", "fg"), ("background", "bg"), + ("selection_foreground", "selection_fg"), + ("selection_background", "selection_bg"), + ("cursor", "cursor"), ("cursor_text_color", "bg"), + ("url_color", "url"), + ("active_border_color", "border_active"), + ("inactive_border_color", "border_inactive"), + ("active_tab_foreground", "tab_active_fg"), + ("active_tab_background", "tab_active_bg"), + ("inactive_tab_foreground", "tab_inactive_fg"), + ("inactive_tab_background", "tab_inactive_bg"), + ("tab_bar_background", "tab_bar_bg"), + ("scrollbar_handle_color", "scrollbar_handle"), + ("scrollbar_track_color", "scrollbar_track"), + ("bell_border_color", "bell_border"), + ("mark1_foreground", "bg"), ("mark1_background", "mark1"), + ("mark2_foreground", "bg"), ("mark2_background", "mark2"), + ("mark3_foreground", "bg"), ("mark3_background", "mark3")]: + lines.append(f"{key:<24}{hex6(res[role])}") + lines.append("") + for i, name in enumerate(ansi): + lines.append(f"color{i:<3}{' ' * 16}{hex6(res[name])}") + for i, name in enumerate(ansi): + lines.append(f"color{i + 8:<3}{' ' * 16}{hex6(res['bright_' + name])}") + return "\n".join(lines) + "\n" + + +def gen_dunst(res, scheme, palette, template): + """dunst: substitute colours into the shipped template. + + Kept as a template rather than fully generated: dunstrc is mostly geometry + and behaviour that has nothing to do with colour. @ACCENT@ is left alone, + because udt-accent substitutes it per wallpaper after pywal renders it. + """ + out = template.replace("@SCHEME@", scheme) + for role in ("bg", "bg_alt", "fg", "fg_dim", "border", "critical"): + out = out.replace(f"@{role.upper()}@", hex6(res[role])) + return out + + +def gen_conky(res, scheme, palette, template): + """conky: substitute into the shipped template. + + conky.text is laid out with absolute ${goto} offsets tuned to label widths, + so it is never regenerated, only its colour block is substituted. + """ + out = template.replace("@SCHEME@", scheme) + for role in ("heading", "label", "rule", "value", "highlight", "ok", + "body", "body_outline", "body_shade"): + out = out.replace(f"@{role.upper()}@", hex6(res[role])) + return out + + +def gen_accent_py(res, scheme, palette, snap_names): + """The accent table and Macchiato dict udt-accent carries for Firefox. + + Written as a Python fragment that udt-accent imports, so the accent snapping + and the pywalfox palette both follow the scheme instead of hardcoding one. + """ + # The snap candidates are declared per scheme, not derived: which hues make + # good accents is a judgement about the palette (drop near-neutrals, drop + # near-duplicate hues) that the colour values alone do not carry. + candidates = {n: palette[n] for n in snap_names} + rows = "\n".join(f' {n!r}: {v!r},' for n, v in candidates.items()) + + ansi = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"] + normal = ", ".join(f'"{hex6(res[n])}"' for n in ansi) + bright = ", ".join(f'"{hex6(res["bright_" + n])}"' for n in ansi) + + return ( + f"# {BANNER.format(scheme=scheme)}\n" + '"""Generated colour tables. See palette/roles.conf."""\n\n' + f"SCHEME = {scheme!r}\n\n" + "# The candidate accents udt-accent snaps a wallpaper to.\n" + f"ACCENTS = {{\n{rows}\n}}\n\n" + f"FALLBACK = {accent_name(res, palette, candidates)!r}\n\n" + "# Firefox, via pywalfox, which reads colors.json and nothing else.\n" + "PALETTE = {\n" + f' "background": "{hex6(res["bg"])}",\n' + f' "foreground": "{hex6(res["fg"])}",\n' + f' "cursor": "{hex6(res["cursor"])}",\n' + f" \"colors\": [{normal},\n" + f" {bright}],\n" + "}\n" + ) + + +def accent_name(res, palette, candidates): + """Which candidate udt-accent falls back to when a wallpaper is too grey. + + The accent role's own colour, so the fallback matches what every consumer + that does not track the wallpaper is already sitting on. + """ + target = hex6(res["accent"]) + for name, hexval in candidates.items(): + if hexval == target: + return name + return next(iter(candidates)) + + +def schemes(): + """Every scheme that ships both a palette and a role map.""" + return sorted(p.stem for p in PALETTE_DIR.glob("*.conf") + if p.stem != "roles" and not p.stem.startswith("roles-")) + + +def load(selector_path, scheme=None): + """Read the selected scheme's palette and role map. + + The selector names a scheme; the scheme names two files. Keeping the role + map per-scheme is what lets a palette use its own colour names: Nord has no + `base` and Dracula no `surface0`, so one shared map could not satisfy both. + """ + if scheme is None: + scheme = parse_conf(selector_path).get("scheme") + if not scheme: + raise ValueError(f"{selector_path}: no 'scheme' line") + + palette_path = PALETTE_DIR / f"{scheme}.conf" + roles_path = PALETTE_DIR / f"roles-{scheme}.conf" + for needed in (palette_path, roles_path): + if not needed.exists(): + raise ValueError( + f"scheme {scheme!r} is missing {needed.name}. " + f"Available: {', '.join(schemes())}") + + roles = parse_conf(roles_path) + palette = parse_conf(palette_path) + for name, value in palette.items(): + if not re.fullmatch(r"#[0-9a-fA-F]{6}", value): + raise ValueError(f"{palette_path}: {name} = {value!r} is not #rrggbb") + snap_names = roles.get("snap", "").split() + if not snap_names: + raise ValueError(f"{roles_path}: no [accents] snap list") + for name in snap_names: + if name not in palette: + raise ValueError( + f"{roles_path}: snap candidate {name!r} is not in the {scheme} palette") + + roles["scheme"] = scheme + return scheme, palette, resolve(roles, palette, roles_path), snap_names + + +# What gets written where. Templates are read from the repo, everything else is +# generated whole. +TARGETS = [ + ("rofi/udt/palette.rasi", gen_rofi, None), + ("templates/waybar/theme.css", gen_waybar, None), + ("templates/terminal/kitty-theme.conf", gen_kitty, None), + ("templates/dunstrc", gen_dunst, "templates/dunstrc.in"), + ("templates/conky.conf", gen_conky, "templates/conky.conf.in"), + ("bin/udt_colors.py", gen_accent_py, None), +] + + +def generate(roles_path, out_dir): + scheme, palette, res, snap_names = load(roles_path) + written = [] + for target, fn, template in TARGETS: + dest = out_dir / target + if template: + src = REPO / template + if not src.exists(): + raise ValueError(f"missing template {src}") + content = fn(res, scheme, palette, src.read_text()) + elif fn is gen_accent_py: + content = fn(res, scheme, palette, snap_names) + else: + content = fn(res, scheme, palette) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content) + written.append(target) + return scheme, written + + +def selftest(): + # Every shipped scheme must satisfy every role, or switching to it breaks + # at install time. load() raises on any role the palette cannot supply. + names = schemes() + assert names, "no palettes found" + + seen = {} + for scheme in names: + _, _, res, snap = load(None, scheme=scheme) + assert res["bg"] != res["fg"], f"{scheme}: bg and fg are the same colour" + assert len(snap) >= 5, f"{scheme}: only {len(snap)} snap candidates" + seen[scheme] = set(res) + print(f" {scheme}: {len(res)} roles, {len(snap)} accents") + + # Every scheme must define the SAME roles: a generator asks for a role by + # name, so one scheme missing it would fail only once that scheme was + # selected, which is exactly the late failure this check exists to prevent. + reference = seen[names[0]] + for scheme, roles in seen.items(): + missing = reference - roles + extra = roles - reference + assert not missing, f"{scheme} is missing roles: {sorted(missing)}" + assert not extra, f"{scheme} has roles no other scheme has: {sorted(extra)}" + + # Alpha survives the round trip into each syntax. + assert hex8((202, 211, 245, 75)) == "#cad3f5bf", hex8((202, 211, 245, 75)) + assert hex8((202, 211, 245, 100)) == "#cad3f5ff" + assert rgba((202, 211, 245, 100)) == "rgb(202,211,245)" + assert rgba((202, 211, 245, 75)) == "rgba(202,211,245,0.75)" + + # A role naming a colour the palette lacks must fail, not emit a hole. + try: + resolve({"scheme": "x", "bad": "nosuchcolour"}, {"base": "#000000"}, "probe") + except ValueError as exc: + assert "nosuchcolour" in str(exc) + else: + raise AssertionError("unknown colour did not raise") + + print("selftest OK") + + +def main(argv): + if "--selftest" in argv: + selftest() + return 0 + + roles_path = PALETTE_DIR / "roles.conf" + out_dir = REPO + if "--roles" in argv: + roles_path = Path(argv[argv.index("--roles") + 1]).expanduser() + if "--out" in argv: + out_dir = Path(argv[argv.index("--out") + 1]).expanduser() + + try: + scheme, written = generate(roles_path, out_dir) + except ValueError as exc: + print(f"udt-palette: {exc}", file=sys.stderr) + return 1 + + print(f"{scheme}: {len(written)} files") + for w in written: + print(f" {w}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) |
