aboutsummaryrefslogtreecommitdiffstats
path: root/bin/udt-palette
diff options
context:
space:
mode:
Diffstat (limited to 'bin/udt-palette')
-rwxr-xr-xbin/udt-palette279
1 files changed, 269 insertions, 10 deletions
diff --git a/bin/udt-palette b/bin/udt-palette
index b2951c6..f2807af 100755
--- a/bin/udt-palette
+++ b/bin/udt-palette
@@ -20,6 +20,13 @@ 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.
@@ -107,17 +114,34 @@ def rgba(c):
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 scheme's own colour names.
+ """rofi: the structural palette, under the names the themes reference.
- 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.
+ 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" {k + ':':11}{v}ff;" for k, v in sorted(palette.items()))
+ 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 */\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"
)
@@ -199,6 +223,176 @@ def gen_kitty(res, scheme, palette):
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.
@@ -324,11 +518,16 @@ TARGETS = [
("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, palette, res, snap_names = load(roles_path)
+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
@@ -347,6 +546,44 @@ def generate(roles_path, out_dir):
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.
@@ -359,7 +596,9 @@ def selftest():
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")
+ 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
@@ -371,6 +610,26 @@ def selftest():
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"
@@ -393,7 +652,7 @@ def main(argv):
selftest()
return 0
- roles_path = PALETTE_DIR / "roles.conf"
+ 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()