diff options
Diffstat (limited to 'bin/udt-accent')
| -rwxr-xr-x | bin/udt-accent | 165 |
1 files changed, 137 insertions, 28 deletions
diff --git a/bin/udt-accent b/bin/udt-accent index e88a512..7949597 100755 --- a/bin/udt-accent +++ b/bin/udt-accent @@ -12,8 +12,10 @@ running `wal -i`, because `wal -i` rewrites the whole ~/.cache/wal directory 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 @@ -43,6 +45,44 @@ 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 palette, which is a symlink back into this repo. Read rather +# than duplicated: palette.rasi is the one place the structural colours are +# written down, and a second copy here would drift the first time one changed. +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): @@ -113,18 +153,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): @@ -154,16 +185,100 @@ 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" + "// The Catppuccin Macchiato 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): + """Rewrite colors.json as Macchiato with the accent in the highlight slots. + + pywalfox reads this file and nothing else, so this is how Firefox tracks the + wallpaper. pywal wrote the file moments earlier with wallpaper-derived ANSI + colours; this replaces them wholesale, which is the point: the terminal + palette stays fixed Macchiato while only the accent moves. + """ + hexval = ACCENTS[name] + colors = list(MACCHIATO["colors"]) + # Slots 4 and 12 are pywalfox's link/highlight colour. + colors[4] = colors[12] = hexval + + doc = { + # 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"], + "cursor": hexval, + }, + "colors": {f"color{i}": c for i, c in enumerate(colors)}, + } + + 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. + subprocess.run(["pywalfox", "update"], capture_output=True, check=False) def write_dunst(name): @@ -180,15 +295,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 @@ -208,7 +315,9 @@ def main(image): write_accent(name) write_border(name) + write_qml(name) write_dunst(name) + write_colors_json(name, image) # Hyprland only rereads its config on request, and may not be running. subprocess.run(["hyprctl", "reload"], capture_output=True, check=False) |
