git.lucas.co / cce-icons
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())