#!/usr/bin/env python3 # udt-accent: pick a Catppuccin Macchiato accent matching a wallpaper. # Copyright (C) 2026 Danilo M. # Licensed under the GNU General Public License v2 only. """Extract a wallpaper's signature color and snap it to a Macchiato accent. Usage: udt-accent udt-accent --selftest The extraction deliberately calls pywal's colorz backend directly rather than 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 math import os import sys import tempfile from pathlib import Path # The nine candidate accents. rosewater and flamingo are excluded as # near-neutral tints that capture saturated inputs; maroon, sapphire and # lavender are excluded as near-duplicate hues of red, sky and mauve. ACCENTS = { "pink": "#f5bde6", "mauve": "#c6a0f6", "red": "#ed8796", "peach": "#f5a97f", "yellow": "#eed49f", "green": "#a6da95", "teal": "#8bd5ca", "sky": "#91d7e3", "blue": "#8aadf4", } FALLBACK = "mauve" MIN_CHROMA = 10.0 OUTPUT = Path.home() / ".cache" / "wal" / "udt-accent.rasi" def _to_lab(hexval): """Convert #rrggbb to CIELAB. sRGB D65, the standard conversion.""" r, g, b = (int(hexval[i:i + 2], 16) / 255 for i in (1, 3, 5)) def linear(c): return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 r, g, b = linear(r), linear(g), linear(b) x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047 y = (0.2126 * r + 0.7152 * g + 0.0722 * b) z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883 def f(t): return t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116 fx, fy, fz = f(x), f(y), f(z) return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)) def _hue(hexval): """Perceptual hue angle in radians.""" _, a, b = _to_lab(hexval) return math.atan2(b, a) def _chroma(hexval): """Distance from the neutral axis. Near-greys sit close to zero.""" _, a, b = _to_lab(hexval) return math.hypot(a, b) def snap(hexval): """Return the name of the nearest candidate accent by perceptual hue.""" if _chroma(hexval) < MIN_CHROMA: return FALLBACK target = _hue(hexval) def distance(name): delta = abs(_hue(ACCENTS[name]) - target) return min(delta, 2 * math.pi - delta) # hue is circular return min(ACCENTS, key=distance) def signature_color(image): """Extract the image's most chromatic mid-tone color. Calls the colorz backend directly. It returns a list and writes nothing, which is what keeps the pywal cache (and so the terminal) untouched. """ from pywal.backends import colorz colors = colorz.get(str(image), 16) # Slot 0 trends near-black and the upper slots near-white; the signature # color of an image lives in the middle. return max(colors[1:7], key=_chroma) def write_accent(name): """Write the accent rasi file atomically.""" hexval = ACCENTS[name] content = ( "/* Generated by udt-accent. Do not edit. */\n" 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 def main(image): try: name = snap(signature_color(image)) except Exception as exc: # A broken image must still leave a working theme. print(f"udt-accent: {exc}, falling back to {FALLBACK}", file=sys.stderr) name = FALLBACK write_accent(name) print(f"{name} {ACCENTS[name]}") def selftest(): # Every accent must snap to itself, or the metric is not self-consistent. for name, hexval in ACCENTS.items(): 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") # A near-grey has an unstable hue angle and must take the fallback. assert snap("#888888") == FALLBACK, snap("#888888") print("selftest OK") if __name__ == "__main__": if len(sys.argv) == 2 and sys.argv[1] == "--selftest": selftest() elif len(sys.argv) == 2: main(sys.argv[1]) else: print(__doc__, file=sys.stderr) sys.exit(2)