diff options
Diffstat (limited to 'bin')
| -rwxr-xr-x | bin/udt-accent | 192 | ||||
| -rwxr-xr-x | bin/udt-appthemes | 102 | ||||
| -rwxr-xr-x | bin/udt-palette | 675 |
3 files changed, 893 insertions, 76 deletions
diff --git a/bin/udt-accent b/bin/udt-accent index 703f483..75058df 100755 --- a/bin/udt-accent +++ b/bin/udt-accent @@ -15,48 +15,49 @@ including the terminal's ANSI colors. See the design spec for why that matters. import json import math import os +import re import subprocess 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, SCHEME # noqa: E402 + MIN_CHROMA = 10.0 OUTPUT = Path.home() / ".cache" / "wal" / "udt-accent.rasi" 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 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. + + Every generated file is watched by something: rofi rereads on launch, + quickshell watches with a FileView. A torn read shows up as a theme that + briefly loses its colours. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") + try: + with os.fdopen(fd, "w") as handle: + handle.write(content) + os.replace(tmp, path) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise def _to_lab(hexval): @@ -127,18 +128,9 @@ def write_accent(name): f"* {{ accent: {hexval}ff; }}\n" ) - OUTPUT.parent.mkdir(parents=True, exist_ok=True) # Write-then-rename: a rofi launch during a wallpaper change must never # read a half-written file. - fd, tmp = tempfile.mkstemp(dir=str(OUTPUT.parent), suffix=".tmp") - try: - with os.fdopen(fd, "w") as handle: - handle.write(content) - os.replace(tmp, OUTPUT) - except BaseException: - if os.path.exists(tmp): - os.unlink(tmp) - raise + write_atomic(OUTPUT, content) def hue_neighbours(name): @@ -168,16 +160,66 @@ def write_border(name): f"return {{ {stops} }}\n" ) - BORDER_OUTPUT.parent.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=str(BORDER_OUTPUT.parent), suffix=".tmp") + write_atomic(BORDER_OUTPUT, content) + + +def read_palette(): + """Parse palette.rasi into {name: "#rrggbb"}. + + rofi writes colours as #rrggbbaa; QML wants #rrggbb, and every entry in + that file is fully opaque, so the alpha pair is dropped rather than + converted. Returns an empty dict if the file is missing, which leaves the + QML palette to fall back to its own defaults. + """ try: - with os.fdopen(fd, "w") as handle: - handle.write(content) - os.replace(tmp, BORDER_OUTPUT) - except BaseException: - if os.path.exists(tmp): - os.unlink(tmp) - raise + text = PALETTE_RASI.read_text() + except OSError: + return {} + + found = {} + for key, value in re.findall(r"(\w+):\s*#([0-9a-fA-F]{6,8})\s*;", text): + found[key] = "#" + value[:6] + return found + + +def write_qml(name): + """Write the palette and current accent as a QML singleton. + + Emitted as QML rather than parsed from palette.rasi by quickshell itself: + the accent has to reach it anyway, so one generated file carrying both + means a component needs a single FileView and no rasi parser. It is + regenerated on every wallpaper change along with the other outputs. + """ + palette = read_palette() + if not palette: + print("udt-accent: palette.rasi unreadable, skipping QML palette", + file=sys.stderr) + return + + rows = "\n".join( + f' readonly property color {key}: "{value}"' + for key, value in sorted(palette.items()) + ) + + content = ( + "// Generated by udt-accent. Do not edit.\n" + "//\n" + f"// The {SCHEME} palette from palette.rasi, plus the accent\n" + "// currently snapped from the wallpaper. Import it from a quickshell\n" + "// component and watch this file to follow theme changes.\n" + "pragma Singleton\n" + "\n" + "import QtQuick\n" + "\n" + "QtObject {\n" + f' readonly property color accent: "{ACCENTS[name]}"\n' + f' readonly property string accentName: "{name}"\n' + "\n" + f"{rows}\n" + "}\n" + ) + + write_atomic(QML_OUTPUT, content) def write_colors_json(name, image): @@ -189,31 +231,25 @@ 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 doc = { - "wallpaper": str(image), + # Resolved, not as passed: wallp gives the real file but a caller may + # give ~/.cache/wal/wpaper, the symlink to it. Same wallpaper either + # way, so record one spelling. + "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)}, } - fd, tmp = tempfile.mkstemp(dir=str(COLORS_JSON.parent), suffix=".tmp") - try: - with os.fdopen(fd, "w") as handle: - json.dump(doc, handle, indent=4) - handle.write("\n") - os.replace(tmp, COLORS_JSON) - except BaseException: - if os.path.exists(tmp): - os.unlink(tmp) - raise + write_atomic(COLORS_JSON, json.dumps(doc, indent=4) + "\n") # Firefox only picks the new colours up when pywalfox pushes them. Never # fatal: pywalfox may not be installed, and the desktop theme is unaffected. @@ -234,15 +270,7 @@ def write_dunst(name): if "@ACCENT@" not in text: return - fd, tmp = tempfile.mkstemp(dir=str(DUNSTRC.parent), suffix=".tmp") - try: - with os.fdopen(fd, "w") as handle: - handle.write(text.replace("@ACCENT@", ACCENTS[name])) - os.replace(tmp, DUNSTRC) - except BaseException: - if os.path.exists(tmp): - os.unlink(tmp) - raise + write_atomic(DUNSTRC, text.replace("@ACCENT@", ACCENTS[name])) # Restart dunst so it rereads the file. It must be started again, not just # killed: nothing else respawns it, and a dead dunst means no notifications @@ -262,6 +290,7 @@ def main(image): write_accent(name) write_border(name) + write_qml(name) write_dunst(name) write_colors_json(name, image) @@ -278,9 +307,14 @@ def selftest(): got = snap(hexval) assert got == name, f"{name} ({hexval}) snapped to {got}" - # Representative real-world inputs. - assert snap("#ff8800") == "peach", snap("#ff8800") - assert snap("#00cc44") == "green", snap("#00cc44") + # Representative real-world inputs. Asserted by hue rather than by name: + # the accent table is generated, so the colour an orange input snaps to is + # called "peach" under Catppuccin and "orange" under Tokyo Night. + for probe in ("#ff8800", "#00cc44", "#3366ff"): + got = snap(probe) + delta = abs(_hue(ACCENTS[got]) - _hue(probe)) + delta = min(delta, 2 * math.pi - delta) + assert delta < 0.6, f"{probe} snapped to {got}, {delta:.2f} rad away" # A near-grey has an unstable hue angle and must take the fallback. assert snap("#888888") == FALLBACK, snap("#888888") @@ -291,7 +325,13 @@ def selftest(): for name in ACCENTS: before, after = hue_neighbours(name) assert len({before, name, after}) == 3, (name, before, after) - assert hue_neighbours("peach") == ("red", "yellow"), hue_neighbours("peach") + # Neighbours are the adjacent hues on the wheel, so each sits nearer to + # the accent than the accent's opposite does. + for name in ACCENTS: + before, after = hue_neighbours(name) + for neighbour in (before, after): + delta = abs(_hue(ACCENTS[neighbour]) - _hue(ACCENTS[name])) + assert min(delta, 2 * math.pi - delta) < math.pi, (name, neighbour) print("selftest OK") diff --git a/bin/udt-appthemes b/bin/udt-appthemes new file mode 100755 index 0000000..37d3e63 --- /dev/null +++ b/bin/udt-appthemes @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# udt-appthemes: install the generated app themes that need more than a copy. +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# Licensed under the GNU General Public License v2 only. +"""Install the Obsidian snippet into every vault, and the Typora theme. + +Separate from install.sh because enabling an Obsidian snippet means editing +JSON that the user also owns: appearance.json lists every enabled snippet, so +the entry has to be appended rather than the file rewritten, or a vault loses +whatever layout snippets it had. +""" + +import json +import shutil +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SNIPPET = "udt" +OBSIDIAN_REGISTRY = Path.home() / ".config" / "obsidian" / "obsidian.json" +TYPORA_THEMES = Path.home() / ".config" / "Typora" / "themes" + + +def vaults(): + """The vaults Obsidian itself knows about. + + Read from Obsidian's own registry rather than found by scanning for + .obsidian directories. A scan cannot tell a vault from an abandoned one: + ~/Documents/Obsidian holds a leftover .obsidian from a vault that no longer + exists, and writing to it touched config for something Obsidian never + opens. + """ + try: + data = json.loads(OBSIDIAN_REGISTRY.read_text()) + except (OSError, json.JSONDecodeError): + return [] + + found = [] + for entry in data.get("vaults", {}).values(): + path = entry.get("path") + if not path: + continue + config = Path(path) / ".obsidian" + if config.is_dir(): + found.append(config) + return sorted(found) + + +def install_obsidian(source): + """Copy the snippet into each vault and enable it, preserving the rest.""" + done = [] + for config in vaults(): + snippets = config / "snippets" + snippets.mkdir(exist_ok=True) + shutil.copyfile(source, snippets / f"{SNIPPET}.css") + + appearance = config / "appearance.json" + try: + data = json.loads(appearance.read_text()) + except (OSError, json.JSONDecodeError): + data = {} + + enabled = data.get("enabledCssSnippets", []) + if SNIPPET not in enabled: + # Append: a vault may already have snippets on, and replacing the + # list would silently switch them off. + enabled.append(SNIPPET) + data["enabledCssSnippets"] = enabled + appearance.write_text(json.dumps(data, indent=2) + "\n") + + done.append(config.parent.name or str(config.parent)) + return done + + +def install_typora(source): + """Drop the theme in. Typora picks it from Themes once the file exists.""" + if not TYPORA_THEMES.is_dir(): + return False + shutil.copyfile(source, TYPORA_THEMES / "udt.css") + return True + + +def main(): + obsidian = REPO / "templates" / "obsidian" / "udt.css" + typora = REPO / "templates" / "typora" / "udt.css" + + if not obsidian.exists() or not typora.exists(): + print("udt-appthemes: run udt-palette first", file=sys.stderr) + return 1 + + names = install_obsidian(obsidian) + if names: + print(f"obsidian: {len(names)} vaults ({', '.join(names)})") + + if install_typora(typora): + print("typora: udt.css installed (select it in Themes)") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/udt-palette b/bin/udt-palette new file mode 100755 index 0000000..f2807af --- /dev/null +++ b/bin/udt-palette @@ -0,0 +1,675 @@ +#!/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" + +# The scheme selection is the user's, not the repo's, so it lives outside the +# working tree: switching scheme should not show up as a diff, and two machines +# sharing this repo can run different schemes. install.sh seeds it from +# palette/roles.conf.default on a first run and never overwrites it after. +SELECTOR = Path.home() / ".config" / "udt" / "roles.conf" +SELECTOR_SEED = PALETTE_DIR / "roles.conf.default" + + +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." + + +# The names the rofi themes reference. They are Catppuccin spellings because +# that is what the themes were written against, but each is filled from a role, +# so a scheme that names nothing "base" still produces a parseable palette. +ROFI_NAMES = [ + ("base", "bg"), ("mantle", "bg_alt"), ("crust", "bg_deep"), + ("surface0", "surface"), ("surface1", "surface_alt"), + ("surface2", "surface_high"), + ("text", "fg"), ("subtext0", "fg_dim"), ("subtext1", "fg_bright"), + ("overlay0", "fg_faint"), ("overlay1", "mid_low"), ("overlay2", "mid_high"), + ("red", "critical"), ("green", "success"), ("yellow", "warning"), + ("teal", "info"), ("blue", "border_active"), ("lavender", "accent"), +] + + +def gen_rofi(res, scheme, palette): + """rofi: the structural palette, under the names the themes reference. + + Every value comes from a role, not from the scheme's own colour names: the + themes ask for @base and @text, and Tokyo Night calls those bg and fg, so + emitting palette names outright left the themes referencing colours that + did not exist and rofi refusing to parse the file. + """ + rows = "\n".join(f" {name + ':':11}{hex8(res[role])};" + for name, role in ROFI_NAMES) + return ( + f"/*\n * {BANNER.format(scheme=scheme)}\n *\n" + " * Structural colors only. The accent lives in accent.rasi.\n" + " * Names are Catppuccin's; values come from the scheme's roles.\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_obsidian(res, scheme, palette): + """Obsidian: a CSS snippet overriding the theme variables. + + A snippet rather than a theme, because a theme replaces the user's choice + outright while a snippet layers over whatever they have enabled. Obsidian + loads snippets after the theme, so these win without the theme having to go. + + The variables are Obsidian's documented public API for this. Only colour is + set: spacing and typography belong to whatever theme the vault uses. + """ + def c(role): + return hex6(res[role]) + + return f"""/* {BANNER.format(scheme=scheme)} + * + * Enabled per vault in Appearance > CSS snippets. install.sh writes this into + * every vault it finds and enables it without disturbing the snippets already + * on, so a vault keeps its layout snippets and gains these colours. + */ + +.theme-dark {{ + --background-primary: {c('bg')}; + --background-primary-alt: {c('bg_alt')}; + --background-secondary: {c('bg_alt')}; + --background-secondary-alt: {c('bg_deep')}; + --background-modifier-border: {c('border')}; + --background-modifier-hover: {c('surface')}; + --background-modifier-error: {c('critical')}; + --background-modifier-success:{c('success')}; + + --text-normal: {c('fg')}; + --text-muted: {c('fg_dim')}; + --text-faint: {c('fg_faint')}; + --text-error: {c('critical')}; + --text-success: {c('success')}; + --text-accent: {c('accent')}; + --text-accent-hover:{c('accent_bright')}; + --text-on-accent: {c('bg')}; + --text-selection: {hex8((*res['accent'][:3], 30))}; + --text-highlight-bg:{hex8((*res['warning'][:3], 40))}; + + --interactive-normal: {c('surface')}; + --interactive-hover: {c('surface_alt')}; + --interactive-accent: {c('accent')}; + --interactive-accent-hover: {c('accent_bright')}; + + --h1-color: {c('accent')}; + --h2-color: {c('accent')}; + --h3-color: {c('info')}; + --h4-color: {c('info')}; + --h5-color: {c('fg_dim')}; + --h6-color: {c('fg_dim')}; + + --code-normal: {c('warning')}; + --code-background: {c('bg_alt')}; + --blockquote-border-color: {c('accent')}; + --hr-color: {c('border')}; + --checkbox-color: {c('accent')}; + --tag-color: {c('info')}; + --tag-background: {hex8((*res['info'][:3], 20))}; +}} +""" + + +def gen_typora(res, scheme, palette, template): + """Typora: substitute the palette into the theme's :root block. + + Typora themes are one self-contained stylesheet, but this one routes all + 350 of its rules through variables in :root, so only that block is a + template. Derived from the dracula theme already installed here. + """ + out = template + for role in TYPORA_ROLES: + out = out.replace(f"@{role.upper()}@", hex6(res[role])) + return out + + +# Exactly the placeholders templates/typora/udt.css.in carries. +TYPORA_ROLES = ["bg", "bg_deep", "surface_alt", "fg", "fg_dim", "fg_faint", + "accent", "accent_bright", "critical", "warning", "success", + "info", "highlight"] + + +KVANTUM_ROLES = ["bg", "bg_alt", "surface", "surface_alt", "surface_high", + "fg", "fg_dim", "fg_faint", "mid_high", "accent", + "accent_dim", "accent_bright", "highlight", "critical"] + + +def gen_kvantum(res, scheme, palette, template): + """Kvantum: substitute the palette into the widget theme. + + A Kvantum theme is a .kvconfig of colours and a .svg of widget artwork with + colours baked into the paths. Only the palette colours are placeholders; + the neutral greys in the SVG are shading and shadow, not theme colour, so + they are left exactly as upstream drew them. + + Derived from the catppuccin-macchiato-lavender theme, which is what this + desktop was already running by hand. + """ + out = template + for role in KVANTUM_ROLES: + out = out.replace(f"@{role.upper()}@", hex6(res[role])) + return out + + +def gen_homepage(res, scheme, palette): + """gethomepage: a custom.css overriding its ten-step colour ramp. + + homepage themes itself with --color-50 (lightest) to --color-900 (darkest) + as space-separated RGB triples, so the ramp is filled from the text scale + at the light end and the surface scale at the dark end. + + The card backgrounds do NOT come from that ramp: in dark mode the service + and bookmark cards carry `dark:bg-white/5`, a literal white, so redefining + the variables alone leaves them a neutral grey. Upstream hits the same wall + and hardcodes those selectors for .theme-white; this does the same for + .theme-gray, which is the class settings.yaml's `color: gray` puts on the + page. Change that setting and this file stops applying. + """ + def triple(role): + r, g, b, _ = res[role] + return f"{r} {g} {b}" + + ramp = [("50", "fg"), ("100", "fg_bright"), ("200", "fg_dim"), + ("300", "mid_high"), ("400", "mid_low"), ("500", "fg_faint"), + ("600", "surface_high"), ("700", "surface_alt"), ("800", "surface"), + ("900", "bg")] + rows = "\n".join(f" --color-{n}: {triple(role)};" for n, role in ramp) + + sr, sg, sb, _ = res["surface"] + ar, ag, ab, _ = res["surface_alt"] + br, bg_, bb, _ = res["bg"] + accent = hex6(res["accent"]) + + return f"""/* {BANNER.format(scheme=scheme)} + * + * Catppuccin-independent: every colour here comes from palette/roles-{scheme} + * .conf, so this file follows the desktop rather than restating a palette. + */ + +.theme-gray {{ +{rows} + + --color-logo-start: {triple('accent')}; + --color-logo-stop: {triple('info')}; +}} + +/* Card backgrounds, which the ramp does not reach. See the note above. */ +.theme-gray .bg-theme-100\\/20:not([class^="backdrop-blur"]), +.theme-gray .dark\\:bg-white\\/5:not([class^="backdrop-blur"]) {{ + background-color: rgb({sr} {sg} {sb} / 55%); +}} + +.theme-gray .bg-theme-100\\/20:hover:not([class^="backdrop-blur"]), +.theme-gray .dark\\:bg-white\\/5:hover:not([class^="backdrop-blur"]) {{ + background-color: rgb({ar} {ag} {ab} / 70%); +}} + +.theme-gray .bg-theme-900\\/50:not([class^="backdrop-blur"]) {{ + background-color: rgb({br} {bg_} {bb} / 50%); +}} + +/* Accent on secondary labels. Fixed, not wallpaper-tracking: this runs on a + * server with no wallpaper to read. */ +.theme-gray .text-theme-500 {{ + color: {accent}; +}} +""" + + +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), + ("templates/homepage/custom.css", gen_homepage, None), + ("templates/obsidian/udt.css", gen_obsidian, None), + ("templates/typora/udt.css", gen_typora, "templates/typora/udt.css.in"), + ("templates/kvantum/theme.kvconfig", gen_kvantum, "templates/kvantum/theme.kvconfig.in"), + ("templates/kvantum/theme.svg", gen_kvantum, "templates/kvantum/theme.svg.in"), +] + + +def generate(roles_path, out_dir, scheme=None): + scheme, palette, res, snap_names = load(roles_path, scheme=scheme) + 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 check_rofi_parses(scheme): + """Render `scheme` to a temp dir and have rofi parse every theme. + + The role checks below prove a scheme resolves, not that what it emits is + valid. A palette missing a name the themes reference parses as a broken + theme and every launcher stops opening, which is how exactly that bug + shipped once. rofi needs no display for -dump-theme. + """ + import shutil + import subprocess + import tempfile + + if not shutil.which("rofi"): + return None + + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) + generate(None, out, scheme=scheme) + udt = out / "rofi" / "udt" + # The themes @import siblings, so they need the whole directory. + for extra in (REPO / "rofi" / "udt").glob("*.rasi"): + if not (udt / extra.name).exists(): + shutil.copy(extra, udt / extra.name) + # accent.rasi is generated per wallpaper; seed it so themes resolve. + (udt / "accent.rasi").write_text("* { accent: #ffffffff; }\n") + + for theme in sorted(udt.glob("*.rasi")): + if theme.name in ("palette.rasi", "accent.rasi", "common.rasi"): + continue + proc = subprocess.run( + ["rofi", "-no-config", "-theme", str(theme), "-dump-theme"], + capture_output=True, text=True) + if "Failed to parse" in proc.stderr: + raise AssertionError( + f"{scheme}: rofi cannot parse {theme.name}\n{proc.stderr.strip()}") + return True + + +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) + parsed = check_rofi_parses(scheme) + note = "" if parsed else " (rofi not installed, parse unchecked)" + print(f" {scheme}: {len(res)} roles, {len(snap)} accents{note}") + + # 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)}" + + # Every name the rofi themes reference must be emitted, or rofi refuses to + # parse the theme and every launcher on the desktop stops opening. This is + # a real regression that shipped: gen_rofi used to emit the scheme's own + # colour names, which only happened to match under Catppuccin. + theme_dir = REPO / "rofi" / "udt" + used = set() + for theme in theme_dir.glob("*.rasi"): + if theme.name in ("palette.rasi", "accent.rasi"): + continue + # Only @name in a colour-property value: @import is a directive and + # @radius a dimension, neither of which this file defines. + # Only @name in a colour-property value. @import is a directive and + # border-radius a dimension, so neither is a colour this file owes. + used |= set(re.findall( + r"(?!border-radius)(?:[\w-]*color|background[\w-]*|border):\s*@(\w+)", + theme.read_text())) + emitted = {name for name, _ in ROFI_NAMES} | {"accent"} + missing = used - emitted + assert not missing, f"rofi themes reference undefined colours: {sorted(missing)}" + + # 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 = SELECTOR if SELECTOR.exists() else SELECTOR_SEED + 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:])) |
