aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-11 09:27:11 +0200
committerDanilo M. <danix@danix.xyz>2026-09-11 09:27:11 +0200
commit4737f3511b8dd9d3f9eb683cb15802e31e188f98 (patch)
treeebd5bdb45db286ea664c2fd91a9d89d9bd823877
parenta13e8df3fad26543969712194324db084a183984 (diff)
downloadunified-desktop-theme-4737f3511b8dd9d3f9eb683cb15802e31e188f98.tar.gz
unified-desktop-theme-4737f3511b8dd9d3f9eb683cb15802e31e188f98.zip
feat: add perceptual hue matching for Macchiato accents
Matches in CIELAB rather than HLS: HLS hue is not perceptually uniform and mismatched obvious cases during prototyping, snapping orange to yellow and saturated red to flamingo. The candidate set is nine, not all fourteen. rosewater and flamingo are near-neutral tints that carry a hue angle but almost no chroma, so they captured saturated inputs; maroon, sapphire and lavender duplicate the hues of red, sky and mauve without adding visible range. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6aeE37K4GHBsaM51yLTTq
-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()