aboutsummaryrefslogtreecommitdiffstats
path: root/bin
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-11 17:20:50 +0200
committerDanilo M. <danix@danix.xyz>2026-09-11 17:20:50 +0200
commit7b55910a786c7652ac80a576399ebd9f81a8ac72 (patch)
tree4adaedb0f0ac5003ecc0e19093a2cb5871b18deb /bin
parentc7cb9dad4e782ee0ff99773c3607ec3b7792c723 (diff)
downloadunified-desktop-theme-7b55910a786c7652ac80a576399ebd9f81a8ac72.tar.gz
unified-desktop-theme-7b55910a786c7652ac80a576399ebd9f81a8ac72.zip
feat(palette): generate every themed config from one scheme
Colours were written down five times: rofi's palette.rasi, the waybar theme, the kitty theme, the conky and dunst templates, and a MACCHIATO dict inside udt-accent. Changing scheme meant finding all five, and they had already drifted: the active waybar theme was Macchiato while theme.css next to it was Mocha, and nothing imported the latter. Now a scheme is two files. palette/<scheme>.conf holds the colours under the scheme's own names, and palette/roles-<scheme>.conf says what each is for. palette/roles.conf is one `scheme =` line selecting the pair. bin/udt-palette renders that into each consumer's syntax and install.sh runs it first. The role map is per-scheme rather than shared because the palettes are genuinely different shapes: Nord has no `base`, Dracula no `surface0`, and forcing Catppuccin's names onto them would have meant either renaming their colours or inventing values. A role names a palette colour with two optional modifiers, `name/75` for alpha and `name*50` for brightness. The second exists because GTK's shade() has no palette name to point at. Ships macchiato, tokyo-night, nord and dracula. --selftest resolves every role in every scheme and asserts they define the same set, so a scheme that cannot satisfy a consumer fails at install rather than when someone switches to it. Verified against the live configs: all 46 waybar colours resolve identically to the previously active theme including its alpha() and shade() derivations, the kitty theme is colour-for-colour identical, and the rofi palette has the same 26 values. Generated files are gitignored; switching scheme and switching back reproduces the originals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gbjA2bmswN8jyPDKzrvqe
Diffstat (limited to 'bin')
-rwxr-xr-xbin/udt-accent49
-rwxr-xr-xbin/udt-palette416
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:]))