#!/usr/bin/env python3 """Generate Makepad Run and WM Android launcher artwork with Python/Pillow. Run from the repository root: python3 tools/app_icons/makepad_android/icons.py Writes each crate's resources/android/res, SVG masters beside this script, preview.png, and preview_row.png when Translator's launcher_row.png exists. Like ../translator/icons.py, all renderers consume the same 108-unit paths; the path, clipping and supersampling helpers are copied/adapted here, with no dependency on that generator. Foreground artwork fits the radius-33 safe circle centred at (54, 54). Only the background extends to the canvas edges. Legacy icons and previews show the launcher's central 72-unit viewport. """ from dataclasses import dataclass, replace from math import hypot from pathlib import Path from PIL import Image, ImageChops, ImageDraw, ImageFont SIZE = 108 KAPPA = 0.5522847498307936 LAUNCHER_VIEWPORT = (18, 18, 90, 90) DENSITIES = {"mdpi": 48, "hdpi": 72, "xhdpi": 96, "xxhdpi": 144, "xxxhdpi": 192} ANDROID_NS = "http://schemas.android.com/apk/res/android" ROOT = Path(__file__).resolve().parents[3] ARTWORK = Path(__file__).resolve().parent # tools/makepad_builder/resources/logo_makepad.svg: rgb(255,92,57). ORANGE = "#ff5c39" # apps/wm/resources/icon.svg: flat average of #1b3354 -> #0b1627, # with the original light and blue. Keep every output on the same flat palette. NAVY = "#13253e" LIGHT = "#e8eef6" BLUE = "#4aa3e0" WHITE = "#ffffff" LAUNCHER_BACKGROUND = "#0b1627" @dataclass(frozen=True) class Shape: path: tuple fill: str cutouts: tuple = () @dataclass(frozen=True) class IconDesign: name: str package: str resources: Path background: tuple foreground: tuple monochrome: tuple @property def artwork(self): return (*self.background, *self.foreground) def polygon(*points): return (("M", *points[0]), *(("L", *p) for p in points[1:]), ("Z",)) def rectangle(x, y, width, height, radius=0, *, bottom_radius=None): """Clockwise rounded rectangle; square bottoms make solid title bars.""" if not radius: return polygon((x, y), (x + width, y), (x + width, y + height), (x, y + height)) r, k = radius, radius * KAPPA b = radius if bottom_radius is None else bottom_radius bk = b * KAPPA return ( ("M", x + r, y), ("L", x + width - r, y), ("C", x + width - r + k, y, x + width, y + r - k, x + width, y + r), ("L", x + width, y + height - b), ("C", x + width, y + height - b + bk, x + width - b + bk, y + height, x + width - b, y + height), ("L", x + b, y + height), ("C", x + b - bk, y + height, x, y + height - b + bk, x, y + height - b), ("L", x, y + r), ("C", x, y + r - k, x + r - k, y, x + r, y), ("Z",), ) CANVAS = rectangle(0, 0, SIZE, SIZE) def designs(): """One source of geometry for Android, SVG, legacy PNGs and previews.""" background = (Shape(CANVAS, NAVY),) # Simplify the logo's six triangles into two peaks above alternating # up/down triangles. Wide gutters survive the 48 px launcher size. triangles = ( polygon((43.5, 29), (34, 45.5), (53, 45.5)), polygon((64.5, 29), (55, 45.5), (74, 45.5)), polygon((33, 47.5), (23.5, 64), (42.5, 64)), polygon((35, 47.5), (54, 47.5), (44.5, 64)), polygon((55, 47.5), (74, 47.5), (64.5, 64)), polygon((75, 47.5), (65.5, 64), (84.5, 64)), ) play = polygon((54, 57), (76, 69), (54, 81)) play_clearance = polygon((50.5, 51.5), (82.5, 69), (50.5, 86.5)) run_mark = ( *(Shape(path, ORANGE, (play_clearance,)) for path in triangles), Shape(play, WHITE), ) run = IconDesign( "run", "dev.makepad.run", ROOT / "tools/run/android_host/resources/android/res", background, run_mark, tuple(replace(shape, fill=WHITE) for shape in run_mark), ) # WM's blue rear window/light title bar and light front window/orange # title bar, without the desktop artwork's tiny controls and text lines. # All outlines are filled paths: no thin strokes at any density. back = rectangle(28, 30, 43, 34, 7.5) back_title = rectangle(28, 30, 43, 10, 7.5, bottom_radius=0) front = rectangle(41, 44, 40, 32, 7) front_title = rectangle(41, 44, 40, 10, 7, bottom_radius=0) front_clearance = rectangle(38.5, 41.5, 45, 37, 9.5) back_pane = rectangle(32.5, 40, 34, 19.5, 2.5) front_pane = rectangle(45.5, 54, 31, 17.5, 2.5) wm = IconDesign( "wm", "nl.makepad.wm", ROOT / "apps/wm/resources/android/res", background, (Shape(back, BLUE, (front_clearance,)), Shape(back_title, LIGHT, (front_clearance,)), Shape(front, LIGHT), Shape(front_title, ORANGE)), # Hollow panes keep both windows legible when Android tints the mark. # Subtract the union of holes, including the front window's clearance. (Shape(back, WHITE, (back_pane, front_clearance)), Shape(front, WHITE, (front_pane,))), ) return (run, wm) def number(value): return f"{value:.6f}".rstrip("0").rstrip(".") or "0" def path_data(path): # Absolute M/L/C/Z commands have identical SVG and Android syntax. return " ".join(command[0] + " ".join(number(v) for v in command[1:]) for command in path) def flatten(path, tolerance): """Adaptively subdivide cubic curves within a raster subpixel tolerance.""" points = [] def midpoint(a, b): return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2) def cubic(a, b, c, d): dx, dy = d[0] - a[0], d[1] - a[1] length = hypot(dx, dy) distance = max(abs(dx * (p[1] - a[1]) - dy * (p[0] - a[0])) / length if length else hypot(p[0] - a[0], p[1] - a[1]) for p in (b, c)) if distance <= tolerance: points.append(d) return ab, bc, cd = midpoint(a, b), midpoint(b, c), midpoint(c, d) abc, bcd = midpoint(ab, bc), midpoint(bc, cd) centre = midpoint(abc, bcd) cubic(a, ab, abc, centre) cubic(centre, bcd, cd, d) for command in path: op, *values = command if op in ("M", "L"): points.append(tuple(values)) elif op == "C": cubic(points[-1], tuple(values[:2]), tuple(values[2:4]), tuple(values[4:])) elif op == "Z": points.append(points[0]) else: raise ValueError(f"Unsupported path command: {op}") return points def raster(shapes, size, *, circle_mask=False, viewport=(0, 0, SIZE, SIZE)): """Draw shared filled paths with Pillow at 4x; downsample exactly once.""" pixels = size * 4 left, top, right, bottom = viewport scale_x, scale_y = pixels / (right - left), pixels / (bottom - top) result = Image.new("RGBA", (pixels, pixels)) def points(path): return [((x - left) * scale_x, (y - top) * scale_y) for x, y in flatten(path, 0.15 / max(scale_x, scale_y))] for shape in shapes: mask = Image.new("L", result.size) draw = ImageDraw.Draw(mask) draw.polygon(points(shape.path), fill=255) for hole in shape.cutouts: draw.polygon(points(hole), fill=0) result.paste(shape.fill, (0, 0, pixels, pixels), mask) if circle_mask: mask = Image.new("L", result.size) ImageDraw.Draw(mask).ellipse((0, 0, pixels - 1, pixels - 1), fill=255) result.putalpha(ImageChops.multiply(result.getchannel("A"), mask)) return result.resize((size, size), Image.Resampling.LANCZOS) def launcher_raster(shapes, size, *, circle_mask=True): return raster(shapes, size, circle_mask=circle_mask, viewport=LAUNCHER_VIEWPORT) def svg_shape(shape, index): element = f' \n' if not shape.cutouts: return element clips, groups = [], [] # Nested outside-of-hole clips subtract a union even when holes overlap. for hole_index, hole in enumerate(shape.cutouts): name = f"cutout-{index}-{hole_index}" outside = path_data(CANVAS) + " " + path_data(hole) clips.append(f' ' f'\n') groups.append(f' \n') return (" \n" + "".join(clips) + " \n" + "".join(groups) + element + " \n" * len(groups)) def svg(shapes): return (f'\n' + "".join(svg_shape(shape, i) for i, shape in enumerate(shapes)) + "\n") def vector(shapes): lines = [f''] for shape in shapes: indent = " " for hole in shape.cutouts: outside = path_data(CANVAS) + " " + path_data(hole) lines.append(f"{indent}") indent += " " lines.append(f'{indent}') lines.append(f'{indent}') for _ in shape.cutouts: indent = indent[:-4] lines.append(f"{indent}") return "\n".join((*lines, "", "")) def adaptive_xml(): return (f'\n' ' \n' ' \n' ' \n' '\n') def preview(icons, large): result = Image.new("RGBA", (552, 576), LAUNCHER_BACKGROUND) draw = ImageDraw.Draw(result) title = ImageFont.load_default(size=20) caption = ImageFont.load_default(size=15) for design, x in zip(icons, (40, 304)): centre = x + 96 draw.text((centre, 24), design.name.upper(), fill=LIGHT, font=title, anchor="mt") draw.text((centre, 51), design.package, fill=LIGHT, font=caption, anchor="mt") result.alpha_composite(large[design.name], (x, 84)) draw.text((centre, 291), "192 px", fill=LIGHT, font=caption, anchor="mt") small = launcher_raster(design.artwork, 48) result.alpha_composite(small.resize((192, 192), Image.Resampling.NEAREST), (x, 332)) draw.text((centre, 540), "48 px / 4x nearest", fill=LIGHT, font=caption, anchor="mt") return result def launcher_composite(source, large): """Replace row slots 3/4 with 192 px renders, resized to 148 px circles.""" result = source.copy() for name, centre in (("run", 667), ("wm", 922)): # Existing circles are centred at y=120.5 in the supplied row. Erase # their entire antialiased rims before placing the requested y=110 # circles; otherwise an old white crescent remains below each icon. left, top, right, bottom = centre - 77, 43, centre + 78, 198 patch = source.crop((left, top, right, bottom)) wallpaper = Image.new("RGBA", patch.size) draw = ImageDraw.Draw(wallpaper) for y in range(patch.height): before = source.getpixel((left - 1, top + y)) after = source.getpixel((right, top + y)) for x in range(patch.width): weight = (x + 1) / (patch.width + 1) color = tuple(round(a + (b - a) * weight) for a, b in zip(before, after)) draw.point((x, y), fill=color) old_circle = Image.new("L", patch.size) ImageDraw.Draw(old_circle).ellipse((2, 2, 153, 153), fill=255) patch.paste(wallpaper, (0, 0), old_circle) result.paste(patch, (left, top)) replacement = large[name].resize((148, 148), Image.Resampling.LANCZOS) result.alpha_composite(replacement, (centre - 74, 110 - 74)) return result def main(): written = [] def write(path, content): path.parent.mkdir(parents=True, exist_ok=True) if isinstance(content, Image.Image): content.save(path, optimize=True) else: path.write_text(content, encoding="utf-8") written.append(path.relative_to(ROOT)) icons = designs() large = {} for design in icons: write(ARTWORK / f"{design.name}.svg", svg(design.artwork)) write(ARTWORK / f"{design.name}_monochrome.svg", svg(design.monochrome)) for name in ("ic_launcher", "ic_launcher_round"): write(design.resources / "mipmap-anydpi-v26" / f"{name}.xml", adaptive_xml()) for layer in ("background", "foreground", "monochrome"): write(design.resources / "drawable" / f"ic_launcher_{layer}.xml", vector(getattr(design, layer))) for density, size in DENSITIES.items(): folder = design.resources / f"mipmap-{density}" write(folder / "ic_launcher.png", launcher_raster(design.artwork, size, circle_mask=False)) write(folder / "ic_launcher_round.png", launcher_raster(design.artwork, size)) large[design.name] = launcher_raster(design.artwork, 192) write(ARTWORK / "preview.png", preview(icons, large)) row_path = ARTWORK.parent / "translator/launcher_row.png" if row_path.exists(): with Image.open(row_path) as source: row = source.convert("RGBA") if row.size != (1080, 337): raise ValueError("launcher_row.png must be the supplied 1080x337 launcher row") write(ARTWORK / "preview_row.png", launcher_composite(row, large)) print("Files written:") for path in written: print(path) if __name__ == "__main__": main()