aboutsummaryrefslogtreecommitdiffstats
path: root/bin/udt-accent
diff options
context:
space:
mode:
Diffstat (limited to 'bin/udt-accent')
-rwxr-xr-xbin/udt-accent192
1 files changed, 116 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")