aboutsummaryrefslogtreecommitdiffstats
path: root/bin
diff options
context:
space:
mode:
Diffstat (limited to 'bin')
-rwxr-xr-xbin/udt-accent192
-rwxr-xr-xbin/udt-appthemes102
-rwxr-xr-xbin/udt-palette675
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:]))