aboutsummaryrefslogtreecommitdiffstats
path: root/bin/udt-accent
blob: a47460e66ec473ece99e45724987865592be3bb1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
#!/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 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)