#!/usr/bin/env python3 """Generates the extension's PNG assets. Everything is drawn at 8x and box filtered down, which is enough anti-aliasing for icons this small and keeps the script free of dependencies. Usage: python3 Tools/make-icons.py """ import math import os import struct import zlib SS = 8 # supersampling factor GREEN = (52, 199, 89) RED = (255, 69, 58) GREY = (142, 142, 147) BLUE = (10, 132, 255) PURPLE = (175, 82, 222) ORANGE = (255, 159, 10) TEAL = (100, 210, 255) WHITE = (255, 255, 255) class Canvas: def __init__(self, size): self.size = size * SS self.scale = SS self.px = [[(0, 0, 0, 0)] * self.size for _ in range(self.size)] def _blend(self, x, y, colour, alpha): if not (0 <= x < self.size and 0 <= y < self.size) or alpha <= 0: return r, g, b = colour dr, dg, db, da = self.px[y][x] na = alpha + da * (1 - alpha) if na == 0: return self.px[y][x] = ( int((r * alpha + dr * da * (1 - alpha)) / na), int((g * alpha + dg * da * (1 - alpha)) / na), int((b * alpha + db * da * (1 - alpha)) / na), na, ) def fill(self, test, colour, alpha=1.0): for y in range(self.size): for x in range(self.size): if test(x + 0.5, y + 0.5): self._blend(x, y, colour, alpha) def erase(self, test): for y in range(self.size): for x in range(self.size): if test(x + 0.5, y + 0.5): self.px[y][x] = (0, 0, 0, 0) # Shapes are described in 0..1 unit space and scaled up here. def u(self, value): return value * self.size def circle(self, cx, cy, r, colour, alpha=1.0): cx, cy, r = self.u(cx), self.u(cy), self.u(r) self.fill(lambda x, y: (x - cx) ** 2 + (y - cy) ** 2 <= r * r, colour, alpha) def ring(self, cx, cy, r, width, colour, alpha=1.0): cx, cy, r, width = self.u(cx), self.u(cy), self.u(r), self.u(width) inner = r - width self.fill( lambda x, y: inner * inner <= (x - cx) ** 2 + (y - cy) ** 2 <= r * r, colour, alpha, ) def ellipse(self, cx, cy, rx, ry, colour, alpha=1.0): cx, cy, rx, ry = self.u(cx), self.u(cy), self.u(rx), self.u(ry) self.fill( lambda x, y: ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1, colour, alpha ) def rect(self, x0, y0, x1, y1, colour, alpha=1.0): x0, y0, x1, y1 = self.u(x0), self.u(y0), self.u(x1), self.u(y1) self.fill(lambda x, y: x0 <= x <= x1 and y0 <= y <= y1, colour, alpha) def rounded(self, x0, y0, x1, y1, radius, colour, alpha=1.0): x0, y0, x1, y1, radius = ( self.u(x0), self.u(y0), self.u(x1), self.u(y1), self.u(radius), ) def test(x, y): if not (x0 <= x <= x1 and y0 <= y <= y1): return False cx = min(max(x, x0 + radius), x1 - radius) cy = min(max(y, y0 + radius), y1 - radius) return (x - cx) ** 2 + (y - cy) ** 2 <= radius * radius self.fill(test, colour, alpha) def line(self, x0, y0, x1, y1, width, colour, alpha=1.0): ax, ay, bx, by, w = ( self.u(x0), self.u(y0), self.u(x1), self.u(y1), self.u(width) / 2, ) dx, dy = bx - ax, by - ay length = math.hypot(dx, dy) or 1 def test(x, y): t = max(0, min(1, ((x - ax) * dx + (y - ay) * dy) / (length * length))) px, py = ax + t * dx, ay + t * dy return (x - px) ** 2 + (y - py) ** 2 <= w * w self.fill(test, colour, alpha) def downsample(self): out = self.size // SS rows = [] for y in range(out): row = bytearray() for x in range(out): r = g = b = a = 0.0 for sy in range(SS): for sx in range(SS): pr, pg, pb, pa = self.px[y * SS + sy][x * SS + sx] r += pr * pa g += pg * pa b += pb * pa a += pa count = SS * SS if a > 0: row += bytes((int(r / a), int(g / a), int(b / a), int(255 * a / count))) else: row += bytes((0, 0, 0, 0)) rows.append(bytes(row)) return out, rows def write_png(path, size, rows): raw = b"".join(b"\x00" + row for row in rows) def chunk(tag, data): payload = tag + data return struct.pack(">I", len(data)) + payload + struct.pack(">I", zlib.crc32(payload)) png = b"\x89PNG\r\n\x1a\n" png += chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)) png += chunk(b"IDAT", zlib.compress(raw, 9)) png += chunk(b"IEND", b"") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb") as handle: handle.write(png) def emit(path, base_size, draw): """Writes name.png at base_size and name@2x.png at double.""" for suffix, size in ((".png", base_size), ("@2x.png", base_size * 2)): canvas = Canvas(size) draw(canvas) out_size, rows = canvas.downsample() write_png(path + suffix, out_size, rows) # --- glyphs ----------------------------------------------------------------- def traffic(colour): def draw(c): c.circle(0.5, 0.5, 0.34, colour) c.circle(0.42, 0.4, 0.11, WHITE, 0.35) return draw def toggle(colour, on): def draw(c): c.rounded(0.06, 0.28, 0.94, 0.72, 0.22, colour) c.circle(0.72 if on else 0.28, 0.5, 0.17, WHITE) return draw def power(colour): def draw(c): c.ring(0.5, 0.54, 0.34, 0.12, colour) c.erase(lambda x, y: abs(x - c.u(0.5)) <= c.u(0.11) and y <= c.u(0.42)) c.rounded(0.44, 0.12, 0.56, 0.52, 0.06, colour) return draw def box(colour): """Stylised container: a crate seen straight on.""" def draw(c): c.rounded(0.1, 0.2, 0.9, 0.82, 0.1, colour) c.erase(lambda x, y: c.u(0.2) <= x <= c.u(0.8) and c.u(0.3) <= y <= c.u(0.72)) c.rect(0.32, 0.3, 0.38, 0.72, colour) c.rect(0.47, 0.3, 0.53, 0.72, colour) c.rect(0.62, 0.3, 0.68, 0.72, colour) return draw def layers(colour): def draw(c): c.rounded(0.12, 0.14, 0.72, 0.5, 0.08, colour) c.rounded(0.28, 0.5, 0.88, 0.86, 0.08, colour, 0.75) return draw def tag(colour): def draw(c): c.rounded(0.1, 0.1, 0.9, 0.9, 0.16, colour) c.erase( lambda x, y: (x - c.u(0.34)) ** 2 + (y - c.u(0.34)) ** 2 <= c.u(0.09) ** 2 ) return draw def volume(colour): def draw(c): c.rect(0.18, 0.24, 0.82, 0.76, colour) c.ellipse(0.5, 0.76, 0.32, 0.14, colour) c.ellipse(0.5, 0.24, 0.32, 0.14, colour) c.ellipse(0.5, 0.24, 0.19, 0.08, WHITE, 0.55) return draw def network(colour): def draw(c): c.line(0.5, 0.28, 0.22, 0.74, 0.08, colour) c.line(0.5, 0.28, 0.78, 0.74, 0.08, colour) c.line(0.22, 0.74, 0.78, 0.74, 0.08, colour) for cx, cy in ((0.5, 0.24), (0.2, 0.76), (0.8, 0.76)): c.circle(cx, cy, 0.16, colour) return draw def gear(colour): def draw(c): for index in range(8): angle = index * math.pi / 4 c.line( 0.5 + 0.2 * math.cos(angle), 0.5 + 0.2 * math.sin(angle), 0.5 + 0.42 * math.cos(angle), 0.5 + 0.42 * math.sin(angle), 0.16, colour, ) c.circle(0.5, 0.5, 0.3, colour) c.erase( lambda x, y: (x - c.u(0.5)) ** 2 + (y - c.u(0.5)) ** 2 <= c.u(0.12) ** 2 ) return draw def folder(colour): def draw(c): c.rounded(0.08, 0.2, 0.5, 0.34, 0.05, colour) c.rounded(0.08, 0.26, 0.92, 0.8, 0.09, colour) return draw def logo(c): """Extension icon: a crate on a rounded plate.""" plate = (28, 30, 34) c.rounded(0.02, 0.02, 0.98, 0.98, 0.22, plate) c.rounded(0.16, 0.26, 0.84, 0.78, 0.09, WHITE) # The crate is hollow: paint the interior back to the plate colour rather # than erasing, which would punch a hole through the plate as well. c.rounded(0.24, 0.34, 0.76, 0.7, 0.04, plate) c.rect(0.34, 0.34, 0.4, 0.7, WHITE) c.rect(0.47, 0.34, 0.53, 0.7, WHITE) c.rect(0.6, 0.34, 0.66, 0.7, WHITE) ICONS = { "Images/icons/status/traffic-on": (16, traffic(GREEN)), "Images/icons/status/traffic-off": (16, traffic(GREY)), "Images/icons/status/toggle-on": (16, toggle(GREEN, True)), "Images/icons/status/toggle-off": (16, toggle(GREY, False)), "Images/icons/status/power-on": (16, power(GREEN)), "Images/icons/status/power-off": (16, power(GREY)), "Images/icons/status/warning": (16, traffic(ORANGE)), "Images/icons/box": (16, box(BLUE)), "Images/icons/layers": (16, layers(PURPLE)), "Images/icons/tag": (16, tag(TEAL)), "Images/icons/volume": (16, volume(ORANGE)), "Images/icons/network": (16, network(BLUE)), "Images/icons/system": (16, gear(GREY)), "Images/icons/folder": (16, folder(GREY)), "Images/sidebar-small": (16, box(WHITE)), "Images/sidebar-large": (32, box(WHITE)), "Images/extension": (128, logo), } def main(): root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) for name, (size, draw) in ICONS.items(): emit(os.path.join(root, name), size, draw) print(f"wrote {name}.png") if __name__ == "__main__": main()