SVG icon set
git clone https://git.lucas.co/cce-icons.git
cursors/build.py (8.9K)
1 #!/usr/bin/env python3
2 """Build the `cce` XCursor theme from the SVG sources in ./svg.
3
4 Renders each source at every size in SIZES, adds the soft dark halo that keeps
5 a white cursor legible on white content, and writes the XCursor binary format
6 directly -- `xcursorgen` is not a dependency (it isn't packaged on this
7 machine, and the format is a header plus one chunk per image).
8
9 Output lands in ./dist/cce (untracked). `--install` copies it to
10 $XDG_DATA_HOME/icons/cce.
11
12 Cursor names come from CURSORS below: each entry maps an SVG to its hotspot
13 (in the 32x32 source viewBox) and every name that should resolve to it --
14 freedesktop names, X11 legacy names, and the MD5-looking names GTK/Qt still
15 ask for. Missing aliases are the usual reason a theme "doesn't work" in some
16 app, so they are part of the data, not an afterthought.
17 """
18
19 import argparse
20 import os
21 import shutil
22 import struct
23 import subprocess
24 import sys
25 from pathlib import Path
26
27 HERE = Path(__file__).resolve().parent
28 SVG_DIR = HERE / "svg"
29 DIST = HERE / "dist" / "cce"
30
31 # Covers scale 1 through 4 at a 24px nominal cursor.
32 SIZES = [24, 32, 48, 64, 96]
33 VIEWBOX = 32.0
34
35 # Soft dark halo, in source-viewBox units, scaled per size.
36 SHADOW_BLUR = 0.55
37 SHADOW_OFFSET = (0.25, 0.4)
38 SHADOW_ALPHA = 0.55
39
40 # name -> (svg stem, hotspot x, hotspot y, [aliases])
41 # Hotspots are in the 32x32 viewBox.
42 CURSORS = [
43 ("default", "plus", 16, 16, [
44 "left_ptr", "arrow", "top_left_arrow", "top_left_corner_arrow",
45 "default", "pointer-arrow",
46 ]),
47 ("crosshair", "crosshair", 16, 16, ["cross", "tcross", "cross_reverse"]),
48 ("text", "text", 16, 16, ["xterm", "ibeam"]),
49 ("vertical-text", "vertical-text", 16, 16, ["vertical_text"]),
50 ("pointer", "pointer", 12, 4, [
51 "hand", "hand1", "hand2", "pointing_hand",
52 "9d800788f1b08800ae810202380a0822", # hand2
53 "e29285e634086352946a0e7090d73106", # hand2
54 ]),
55 ("progress", "progress", 11, 11, [
56 "half-busy", "left_ptr_watch",
57 "08e8e1c95fe2fc01f976f1e063a24ccd",
58 "3ecb610c1bf2410f44200f48c40d3599",
59 "00000000000000020006000e7e9ffc3f",
60 "9116a3ea924ed2162ecab71ba103b17f",
61 ]),
62 ("wait", "wait", 16, 16, ["watch", "clock"]),
63 ("help", "help", 11, 11, [
64 "question_arrow", "whats_this", "dnd-ask", "left_ptr_help",
65 "5c6cd98b3f3ebcb1f9c7f1c204630408",
66 "d9ce0ab605698f320427677b458ad60b",
67 ]),
68 ("context-menu", "context-menu", 11, 11, []),
69 ("not-allowed", "not-allowed", 16, 16, [
70 "forbidden", "crossed_circle", "circle",
71 "03b6e0fcb3499374a867c041f52298f0",
72 ]),
73 ("no-drop", "not-allowed", 16, 16, [
74 "dnd-none", "dnd_no_drop",
75 "b66166c04f8c3109214a4fbd64a50fc8",
76 ]),
77 ("copy", "copy", 11, 11, [
78 "dnd-copy", "copycur",
79 "1081e37283d90000800003c07f3ef6bf",
80 "6407b0e94181790501fd1e167b474872",
81 ]),
82 ("alias", "alias", 11, 11, [
83 "dnd-link", "link",
84 "640fb0e74195791501fd1ed57b41487f",
85 "a2a266d0498c3104214a47bd64ab0fc8",
86 ]),
87 ("move", "move", 16, 16, [
88 "fleur", "size_all", "all-scroll", "dnd-move",
89 "4498f0e0c1937ffe01fd06f973665830",
90 "fcf21c00b30f7e3f83fe0dfd12e71cff",
91 "38c5dff7c7b8962045400281044508d2",
92 ]),
93 ("grab", "grab", 16, 16, ["openhand", "hand1-grab"]),
94 ("grabbing", "grabbing", 16, 16, [
95 "closedhand", "dnd-grabbing",
96 "08ffe1cb5fe6fc01f906f1c063814ccf",
97 "5aca4d189052212118709018842178c0",
98 ]),
99 ("cell", "cell", 16, 16, ["plus"]),
100 ("zoom-in", "zoom-in", 14, 14, ["zoom_in"]),
101 ("zoom-out", "zoom-out", 14, 14, ["zoom_out"]),
102 ("ns-resize", "ns-resize", 16, 16, [
103 "n-resize", "s-resize", "row-resize", "v_double_arrow",
104 "sb_v_double_arrow", "split_v", "double_arrow", "size_ver",
105 "top_side", "bottom_side", "based_arrow_up", "based_arrow_down",
106 "00008160000006810000408080010102",
107 "2870a09082c103050810ffdffffe0204",
108 ]),
109 ("ew-resize", "ew-resize", 16, 16, [
110 "e-resize", "w-resize", "col-resize", "h_double_arrow",
111 "sb_h_double_arrow", "split_h", "size_hor",
112 "left_side", "right_side",
113 "028006030e0e7ebffc7f7070c0600140",
114 "14fef782d02440884392942c11205230",
115 ]),
116 ("nesw-resize", "nesw-resize", 16, 16, [
117 "ne-resize", "sw-resize", "size_bdiag", "fd_double_arrow",
118 "top_right_corner", "bottom_left_corner",
119 "fcf1c3c7cd4491d801f1e1c78f100000",
120 ]),
121 ("nwse-resize", "nwse-resize", 16, 16, [
122 "nw-resize", "se-resize", "size_fdiag", "bd_double_arrow",
123 "top_left_corner", "bottom_right_corner",
124 "c7088f0f3e6c8088236ef8e1e3e70000",
125 ]),
126 ("pencil", "pencil", 5, 27, ["draft", "draft_large", "draft_small"]),
127 ("up-arrow", "up-arrow", 16, 4, ["center_ptr", "sb_up_arrow"]),
128 ]
129
130 XC_IMAGE = 0xFFFD0002
131
132
133 def render(svg: Path, size: int):
134 """Render `svg` at `size`x`size` and composite the halo. Returns RGBA bytes."""
135 from PIL import Image, ImageFilter
136
137 out = subprocess.run(
138 ["magick", "-background", "none", "-density", str(int(size / VIEWBOX * 96)),
139 str(svg), "-resize", f"{size}x{size}", "png32:-"],
140 check=True, capture_output=True,
141 ).stdout
142 import io
143 img = Image.open(io.BytesIO(out)).convert("RGBA")
144 if img.size != (size, size):
145 canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
146 canvas.paste(img, ((size - img.width) // 2, (size - img.height) // 2))
147 img = canvas
148
149 scale = size / VIEWBOX
150 blur = SHADOW_BLUR * scale
151 if blur > 0.05:
152 alpha = img.getchannel("A").filter(ImageFilter.GaussianBlur(blur))
153 alpha = alpha.point(lambda a: int(a * SHADOW_ALPHA))
154 shadow = Image.new("RGBA", img.size, (0, 0, 0, 0))
155 shadow.putalpha(alpha)
156 dx = int(round(SHADOW_OFFSET[0] * scale))
157 dy = int(round(SHADOW_OFFSET[1] * scale))
158 canvas = Image.new("RGBA", img.size, (0, 0, 0, 0))
159 canvas.paste(shadow, (dx, dy), shadow)
160 img = Image.alpha_composite(canvas, img)
161 return img.tobytes()
162
163
164 def xcursor(frames):
165 """Serialize [(size, w, h, xhot, yhot, delay, rgba)] into XCursor bytes.
166
167 Pixels go out as premultiplied ARGB32 little-endian, i.e. B,G,R,A on the
168 wire -- the layout libXcursor and wlroots both read back.
169 """
170 ntoc = len(frames)
171 header = struct.pack("<4sIII", b"Xcur", 16, 0x10000, ntoc)
172 toc_size = ntoc * 12
173 pos = 16 + toc_size
174 toc, chunks = b"", b""
175 for size, w, h, xhot, yhot, delay, rgba in frames:
176 toc += struct.pack("<III", XC_IMAGE, size, pos)
177 body = bytearray(w * h * 4)
178 for i in range(0, len(rgba), 4):
179 r, g, b, a = rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]
180 body[i] = b * a // 255
181 body[i + 1] = g * a // 255
182 body[i + 2] = r * a // 255
183 body[i + 3] = a
184 chunk = struct.pack("<IIIIIIIII", 36, XC_IMAGE, size, 1,
185 w, h, xhot, yhot, delay) + bytes(body)
186 chunks += chunk
187 pos += len(chunk)
188 return header + toc + chunks
189
190
191 def main():
192 ap = argparse.ArgumentParser()
193 ap.add_argument("--install", action="store_true",
194 help="copy the built theme into $XDG_DATA_HOME/icons/cce")
195 ap.add_argument("--only", help="build just this cursor name (fast iteration)")
196 args = ap.parse_args()
197
198 cursors_out = DIST / "cursors"
199 cursors_out.mkdir(parents=True, exist_ok=True)
200
201 built, skipped, links = 0, [], 0
202 for name, stem, hx, hy, aliases in CURSORS:
203 if args.only and name != args.only:
204 continue
205 svg = SVG_DIR / f"{stem}.svg"
206 if not svg.exists():
207 skipped.append(f"{name} (no {stem}.svg)")
208 continue
209
210 frames = []
211 for size in SIZES:
212 rgba = render(svg, size)
213 xhot = int(round(hx / VIEWBOX * size))
214 yhot = int(round(hy / VIEWBOX * size))
215 frames.append((size, size, size, xhot, yhot, 0, rgba))
216
217 (cursors_out / name).write_bytes(xcursor(frames))
218 built += 1
219
220 for alias in aliases:
221 if alias == name:
222 continue
223 link = cursors_out / alias
224 if link.is_symlink() or link.exists():
225 link.unlink()
226 link.symlink_to(name)
227 links += 1
228
229 (DIST / "index.theme").write_text(
230 "[Icon Theme]\nName=cce\nComment=CCE desktop cursors\n"
231 )
232 (DIST / "cursor.theme").write_text("[Icon Theme]\nName=cce\nInherits=cce\n")
233
234 print(f"built {built} cursors x {len(SIZES)} sizes, {links} aliases -> {DIST}")
235 if skipped:
236 print("skipped (source missing): " + ", ".join(skipped))
237
238 if args.install:
239 data = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share")
240 target = Path(data) / "icons" / "cce"
241 if target.exists():
242 shutil.rmtree(target)
243 shutil.copytree(DIST, target, symlinks=True)
244 print(f"installed -> {target}")
245 return 0
246
247
248 if __name__ == "__main__":
249 sys.exit(main())