diff options
Diffstat (limited to 'bin')
| -rwxr-xr-x | bin/udt-accent | 101 |
1 files changed, 101 insertions, 0 deletions
diff --git a/bin/udt-accent b/bin/udt-accent new file mode 100755 index 0000000..e459a35 --- /dev/null +++ b/bin/udt-accent @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# udt-accent: pick a Catppuccin Macchiato accent matching a wallpaper. +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# 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 <wallpaper-path> + 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 sys + +# 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 + + +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 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() |
