diff options
Diffstat (limited to 'bin/udt-accent')
| -rwxr-xr-x | bin/udt-accent | 192 |
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") |
