diff options
Diffstat (limited to 'bin')
| -rwxr-xr-x | bin/udt-accent | 49 | ||||
| -rwxr-xr-x | bin/udt-palette | 416 |
2 files changed, 428 insertions, 37 deletions
diff --git a/bin/udt-accent b/bin/udt-accent index 7949597..1a222b5 100755 --- a/bin/udt-accent +++ b/bin/udt-accent @@ -21,25 +21,12 @@ import sys import tempfile from pathlib import Path -# The candidate accents. rosewater and flamingo are excluded as near-neutral -# tints that capture saturated inputs, and maroon and sapphire as near-duplicate -# hues of red and sky. lavender sits close to mauve but is kept: it is the -# desktop-wide fallback, used by Qt, GTK and anything else that needs one fixed -# accent, so it has to be a value snap() can also return. -ACCENTS = { - "pink": "#f5bde6", - "mauve": "#c6a0f6", - "lavender": "#b7bdf8", - "red": "#ed8796", - "peach": "#f5a97f", - "yellow": "#eed49f", - "green": "#a6da95", - "teal": "#8bd5ca", - "sky": "#91d7e3", - "blue": "#8aadf4", -} - -FALLBACK = "lavender" +# The colour tables are generated: udt-palette renders them from +# palette/<scheme>.conf and palette/roles-<scheme>.conf, so the accents this +# snaps to follow whatever scheme is selected. Regenerate with ./install.sh. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from udt_colors import ACCENTS, FALLBACK, PALETTE # noqa: E402 + MIN_CHROMA = 10.0 OUTPUT = Path.home() / ".cache" / "wal" / "udt-accent.rasi" @@ -47,24 +34,12 @@ BORDER_OUTPUT = Path.home() / ".cache" / "wal" / "udt-border.lua" DUNSTRC = Path.home() / ".cache" / "wal" / "dunstrc" QML_OUTPUT = Path.home() / ".cache" / "wal" / "udt-palette.qml" -# The installed palette, which is a symlink back into this repo. Read rather -# than duplicated: palette.rasi is the one place the structural colours are -# written down, and a second copy here would drift the first time one changed. +# The installed rofi palette, itself generated by udt-palette. Read rather than +# duplicated so the QML singleton carries exactly the colours rofi uses, under +# the same names. PALETTE_RASI = Path.home() / ".config" / "rofi" / "udt" / "palette.rasi" COLORS_JSON = Path.home() / ".cache" / "wal" / "colors.json" -# Catppuccin Macchiato, in pywal's slot order. Firefox is themed from this -# via pywalfox, which reads colors.json and nothing else. -MACCHIATO = { - "background": "#24273a", "foreground": "#cad3f5", "cursor": "#f4dbd6", - "colors": [ - "#494d64", "#ed8796", "#a6da95", "#eed49f", - "#8aadf4", "#f5bde6", "#8bd5ca", "#b8c0e0", - "#5b6078", "#ed8796", "#a6da95", "#eed49f", - "#8aadf4", "#f5bde6", "#8bd5ca", "#a5adcb", - ], -} - def write_atomic(path, content): """Write a file atomically, so no reader ever sees it half-written. @@ -256,7 +231,7 @@ def write_colors_json(name, image): palette stays fixed Macchiato while only the accent moves. """ hexval = ACCENTS[name] - colors = list(MACCHIATO["colors"]) + colors = list(PALETTE["colors"]) # Slots 4 and 12 are pywalfox's link/highlight colour. colors[4] = colors[12] = hexval @@ -267,8 +242,8 @@ def write_colors_json(name, image): "wallpaper": os.path.realpath(image), "alpha": "100", "special": { - "background": MACCHIATO["background"], - "foreground": MACCHIATO["foreground"], + "background": PALETTE["background"], + "foreground": PALETTE["foreground"], "cursor": hexval, }, "colors": {f"color{i}": c for i, c in enumerate(colors)}, 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:])) |
