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