diff options
Diffstat (limited to 'bin/udt-palette')
| -rwxr-xr-x | bin/udt-palette | 299 |
1 files changed, 287 insertions, 12 deletions
diff --git a/bin/udt-palette b/bin/udt-palette index b2951c6..a746766 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" ) @@ -126,8 +150,15 @@ 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. + and the stylesheets reference names from each: @lavender and @surface1 by + Catppuccin name, @cpu and @hover-bg by role. + + The Catppuccin-named half is emitted from roles, exactly as gen_rofi does + and for the same reason: styles/main.css and styles/global.css are edited + in place, not generated, and ask for @surface1 and @text outright. Tokyo + Night calls those surface_alt and fg, so emitting only the scheme's own + palette names left every such reference dangling and GTK fell back to a + default, which is how the bar's separators went bright. Role names keep their existing hyphenated spelling (main-bg, not main_bg): the stylesheets reference them and are not generated. @@ -135,6 +166,12 @@ def gen_waybar(res, scheme, palette): palette_rows = "\n".join(f"@define-color {k:<12}{v};" for k, v in sorted(palette.items())) + # The names the hand-edited stylesheets reference. ROFI_NAMES already maps + # most of them; mauve is the one waybar uses and rofi does not. + compat_rows = "\n".join( + f"@define-color {name:<12}{rgba(res[role])};" + for name, role in [*ROFI_NAMES, ("mauve", "highlight")]) + def emit(role, css_name=None): c = res[role] return f"@define-color {(css_name or role.replace('_', '-')):<12}{rgba(c)};" @@ -149,6 +186,9 @@ def gen_waybar(res, scheme, palette): "", palette_rows, "", + "/* Catppuccin names, from roles: the hand-edited stylesheets use these. */", + compat_rows, + "", "/* br - border, bg - background, fg - foreground */", "", "/* main colors */", @@ -199,6 +239,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 +534,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 +562,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 +612,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 +626,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 +668,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() |
