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