#!/usr/bin/env python3 """Makes the apps' icons, one family (ADR 0031): each app's Lucide glyph on a white square (TILE; D10, back from D8's dark one), fading from cobalt through the colours APPS names, top left to bottom right across Lucide's 24-unit box, the stops evenly spaced, at Lucide's stroke width (2), or, for Halkyon (Learn), the kingfisher in its own colours on the same square (ADR 0045, D14; ADR 0031, D12), 1024 x 1024 pixels (//assets/icon/); the launchers and the launch screens round its corners themselves. Then, for each app: - Lucide's licence (ISC) beside a glyph's icon (assets/icon/LICENSE-lucide.txt), which the app registers in its licence list; - the snap's icon where the app has a snap (Learn's snap/gui/halkyon-learn.png at the repository's root, Todo's apps/todo/snap/gui/halkyon-todo.png; 512 x 512 pixels, the Snap Store's size), the icon with rounded corners, as other apps' tiles have (radius 142.222 of 512, about 28 %); - through flutter_launcher_icons, configured in the app's pubspec: Android's adaptive icon with its monochrome layer, which themed icons tint (D9), and its older square one, iOS's with its dark and tinted variants (D9), macOS's (the rounded tile on Apple's grid, 824 of 1024 pixels) and Windows' (the rounded tile), from the images derived in the app's build/icons/, which git ignores; - Windows' .ico once more, with every size Windows shows (16 to 256 pixels): the tool writes only the 256 one and leaves Windows to scale it down for its small views; - the kingfisher for the app's launch screen (assets/icon/halkyon__kingfisher.png, ADR 0031, D11): the commissioned drawing (tool/kingfisher.svg, unchanged), on transparency, cut to the bird; Halkyon's in its own colours (D12), the others' with its warm parts, orange and rust, in the last two of the app's colours and its accents in the second last, lifted, its cobalt every app's; - the iOS project file as it was: the tool sets every build setting with ASSETCATALOG in its name to the icon set's name, Xcode's yes-or-no ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS too, which breaks the project (flutter_launcher_icons issue 634); the project names its icon set already. The glyphs come from lucide-static at a pinned release (LUCIDE below), fetched from jsDelivr, and are drawn by librsvg, as they have arcs and curves (bike's wheels). Run from the repository's root with Python 3, Pillow, PyGObject with librsvg and pycairo (Ubuntu's python3-pil, python3-gi, gir1.2-rsvg-2.0, python3-cairo), the apps' dependencies fetched, naming the apps to draw, or none for all of them: python3 -I tool/app_icons.py train """ import subprocess import sys import urllib.request from dataclasses import dataclass from io import BytesIO from pathlib import Path import cairo import gi gi.require_version("Rsvg", "2.0") from gi.repository import GLib, Rsvg # noqa: E402 (after the version is required) from PIL import Image, ImageDraw # noqa: E402 LUCIDE = "1.50.0" CDN = f"https://cdn.jsdelivr.net/npm/lucide-static@{LUCIDE}" COBALT = "#3C57E5" VIOLET = "#8E4FD6" STRAWBERRY = "#EC3E7C" CARROT = "#F78400" PACIFIC = "#179FD9" FERN = "#3CBB3A" REEF = "#1DA583" ENZIAN = "#5252CC" PURPLE = "#8080FF" # ADR 0031, D10: the tile is white again (D1), the gradient as APPS gives it. iOS's # dark icon (D9) sits on iOS's own dark background, so there every stop is mixed this # share towards white (D8's lift), keeping cobalt above 4.5 : 1 against it. TILE = "#FFFFFF" DARK_LIFT = 0.3 @dataclass(frozen=True) class AppIcon: """An app's icon: where it goes, its glyph and its gradient's colours.""" folder: str package: str icon: str glyph: str stops: tuple[str, ...] snap: str | None = None # Its icon is the kingfisher in its own colours, not the glyph (ADR 0045, D14). bird: bool = False # ADR 0031, D5: each from cobalt into its app's backdrop. APPS = { "learn": AppIcon( "apps/learn", "halkyon_learn_flutter", "halkyon_learn_icon.png", "graduation-cap", (COBALT, ENZIAN, PURPLE), snap="snap/gui/halkyon-learn.png", bird=True, ), "todo": AppIcon( "apps/todo", "halkyon_todo_flutter", "halkyon_todo_icon.png", "list-todo", (COBALT, VIOLET, STRAWBERRY), snap="apps/todo/snap/gui/halkyon-todo.png", ), "train": AppIcon( "apps/train", "halkyon_train_flutter", "halkyon_train_icon.png", "dumbbell", (COBALT, VIOLET, STRAWBERRY, CARROT), snap="apps/train/snap/gui/halkyon-train.png", ), "meditate": AppIcon( "apps/meditate", "halkyon_meditate_flutter", "halkyon_meditate_icon.png", "sprout", (COBALT, PACIFIC, FERN), ), } SIZE = 1024 # The 24-unit box Lucide draws in takes this share of the square, centred: well # inside the circle Android's adaptive icons keep (66 %). GLYPH = 0.6 # The corners the launch screen and launchers round to. RADIUS = 142.222 / 512 SUPERSAMPLING = 4 SNAP_SIZE = 512 # Apple's grid: the tile is 824 of the icon's 1024 pixels, centred. MACOS_CANVAS = 1024 MACOS_TILE = 824 WINDOWS_SIZES = [16, 20, 24, 32, 40, 48, 64, 128, 256] KINGFISHER = Path("tool/kingfisher.svg") # The launch screen shows it at 160 logical pixels: three times that. KINGFISHER_SIZE = 480 # The square around the bird in the drawing's 512-unit box. KINGFISHER_BOX = "86 89 340 340" # On an icon the square around the bird takes this share of it, centred, smaller # than a glyph's: its beak reaches into the corner, and at this size it stays inside # the 66 dp circle of the 108 Android keeps on every launcher's shape (0.306). BIRD = 0.47 def fetch(name: str) -> str: with urllib.request.urlopen(f"{CDN}/{name}", timeout=30) as response: return response.read().decode("utf-8") def rgb(colour: str) -> tuple[float, float, float]: return tuple(int(colour[i : i + 2], 16) / 255 for i in (1, 3, 5)) def lifted(colour: str, lift: float) -> tuple[float, float, float]: """[colour] mixed [lift] towards white.""" return tuple(c + (1 - c) * lift for c in rgb(colour)) def glyph_mask(glyph: str, svg: str) -> cairo.ImageSurface: """[glyph]'s strokes alone, SIZE pixels square, where every icon draws it.""" if f"lucide-static v{LUCIDE}" not in svg: raise SystemExit(f"Not lucide-static {LUCIDE}'s {glyph}.") if 'viewBox="0 0 24 24"' not in svg or 'stroke-width="2"' not in svg: raise SystemExit(f"{glyph} isn't drawn in Lucide's 24 units at width 2.") handle = Rsvg.Handle.new_from_data(svg.replace("currentColor", "#000").encode()) box = SIZE * GLYPH offset = (SIZE - box) / 2 mask = cairo.ImageSurface(cairo.FORMAT_ARGB32, SIZE, SIZE) viewport = Rsvg.Rectangle() viewport.x, viewport.y, viewport.width, viewport.height = offset, offset, box, box try: handle.render_document(cairo.Context(mask), viewport) except GLib.Error as error: raise SystemExit(f"librsvg couldn't draw {glyph}: {error.message}") return mask def gradient_of(stops: tuple[str, ...], lift: float = 0) -> cairo.LinearGradient: """The gradient of [stops], each mixed [lift] towards white, across the glyph's box, top left to bottom right.""" box = SIZE * GLYPH offset = (SIZE - box) / 2 gradient = cairo.LinearGradient(offset, offset, offset + box, offset + box) for i, colour in enumerate(stops): gradient.add_color_stop_rgb(i / (len(stops) - 1), *lifted(colour, lift)) return gradient def drawn( mask: cairo.ImageSurface, source: cairo.Pattern, background: tuple[float, float, float] | None, ) -> Image.Image: """The glyph's [mask] filled with [source], on [background] or, without one, on transparency.""" surface = cairo.ImageSurface( cairo.FORMAT_ARGB32 if background is None else cairo.FORMAT_RGB24, SIZE, SIZE ) context = cairo.Context(surface) if background is not None: context.set_source_rgb(*background) context.paint() context.set_source(source) context.mask_surface(mask) png = BytesIO() surface.write_to_png(png) png.seek(0) return Image.open(png).convert("RGB" if background is not None else "RGBA") def icon(mask: cairo.ImageSurface, stops: tuple[str, ...]) -> Image.Image: """The glyph's [mask] on TILE in the gradient of [stops].""" return drawn(mask, gradient_of(stops), rgb(TILE)) def themed( mask: cairo.ImageSurface, stops: tuple[str, ...], bird: bool = False ) -> dict[str, Image.Image]: """The variants the systems colour themselves (ADR 0031, D9), by their file names in build/icons/: Android's monochrome layer (the glyph in white on transparency, which the launcher tints from the wallpaper), iOS's dark icon (the glyph in its gradient, lifted by DARK_LIFT, on transparency, which iOS sets on its own dark background) and iOS's tinted one (the glyph in white on black, a grayscale image iOS tints). For the [bird], its outline in white, and on iOS's dark background the bird itself (ADR 0045, D14).""" white = cairo.SolidPattern(1, 1, 1) return { "monochrome.png": drawn(mask, white, None), "ios_dark.png": image_of(mask) if bird else drawn(mask, gradient_of(stops, DARK_LIFT), None), "ios_tinted.png": drawn(mask, white, (0, 0, 0)), } def hex_of(colour: tuple[float, float, float]) -> str: return "#" + "".join(f"{round(c * 255):02x}" for c in colour) def bird_surface( stops: tuple[str, ...] | None, size: int, offset: float, box: float ) -> cairo.ImageSurface: """The kingfisher on transparency, [size] pixels square, the square around the bird drawn [box] pixels wide at [offset] from the top left; in the colours of [stops] (D11), or in its own without.""" svg = KINGFISHER.read_text(encoding="utf-8") tile = '' if tile not in svg: raise SystemExit(f"{KINGFISHER} isn't the commissioned drawing.") # Its own white tile goes: the bird stands on the launch screen, or on TILE. svg = svg.replace(tile, "") svg = svg.replace('viewBox="0 0 512 512"', f'viewBox="{KINGFISHER_BOX}"') if stops is not None: end, between = stops[-1], stops[-2] for drawn_as, becomes in { "#f78400": end, "#c44800": between, "#5252cc": between, "#179fd9": hex_of(lifted(between, DARK_LIFT)), }.items(): svg = svg.replace(drawn_as, becomes.lower()) handle = Rsvg.Handle.new_from_data(svg.encode()) surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) viewport = Rsvg.Rectangle() viewport.x, viewport.y = offset, offset viewport.width = viewport.height = box try: handle.render_document(cairo.Context(surface), viewport) except GLib.Error as error: raise SystemExit(f"librsvg couldn't draw the kingfisher: {error.message}") return surface def image_of(surface: cairo.ImageSurface) -> Image.Image: out = BytesIO() surface.write_to_png(out) return Image.open(BytesIO(out.getvalue())).convert("RGBA") def kingfisher(stops: tuple[str, ...] | None) -> Image.Image: """The kingfisher for a launch screen, KINGFISHER_SIZE square, in the colours of [stops] (D11), or in its own without (D12).""" return image_of(bird_surface(stops, KINGFISHER_SIZE, 0, KINGFISHER_SIZE)) def bird_mask() -> cairo.ImageSurface: """The kingfisher in its own colours, SIZE pixels square, where a glyph stands on an icon (ADR 0045, D14): its shape is the themed variants' mask.""" box = SIZE * BIRD return bird_surface(None, SIZE, (SIZE - box) / 2, box) def rounded(source: Image.Image, size: int) -> Image.Image: """[source] at [size] pixels, its corners rounded off to transparency.""" picture = source.convert("RGBA").resize((size, size), Image.LANCZOS) large = size * SUPERSAMPLING mask = Image.new("L", (large, large), 0) ImageDraw.Draw(mask).rounded_rectangle( (0, 0, large - 1, large - 1), radius=RADIUS * large, fill=255 ) picture.putalpha(mask.resize((size, size), Image.LANCZOS)) return picture def make(app: AppIcon, license_text: str) -> None: package = Path(app.folder) / app.package if app.bird: mask = bird_mask() source = drawn(mask, cairo.SurfacePattern(mask), rgb(TILE)) else: mask = glyph_mask(app.glyph, fetch(f"icons/{app.glyph}.svg")) source = icon(mask, app.stops) path = package / "assets/icon" / app.icon path.parent.mkdir(parents=True, exist_ok=True) source.save(path, optimize=True) print(f"Wrote {path}.") license_path = package / "assets/icon/LICENSE-lucide.txt" if app.bird: license_path.unlink(missing_ok=True) else: license_path.write_text(license_text, encoding="utf-8") print(f"Wrote {license_path}.") emblem = package / "assets/icon" / app.icon.replace("_icon.png", "_kingfisher.png") kingfisher(None if app.bird else app.stops).save(emblem, optimize=True) print(f"Wrote {emblem}.") if app.snap: rounded(source, SNAP_SIZE).save(app.snap, optimize=True) print(f"Wrote {app.snap}.") derived = package / "build/icons" derived.mkdir(parents=True, exist_ok=True) rounded(source, 1024).save(derived / "rounded.png") macos = Image.new("RGBA", (MACOS_CANVAS, MACOS_CANVAS), (0, 0, 0, 0)) margin = (MACOS_CANVAS - MACOS_TILE) // 2 macos.alpha_composite(rounded(source, MACOS_TILE), (margin, margin)) macos.save(derived / "macos.png") for name, picture in themed(mask, app.stops, app.bird).items(): picture.save(derived / name) ios_project = package / "ios/Runner.xcodeproj/project.pbxproj" kept = ios_project.read_bytes() subprocess.run(["dart", "run", "flutter_launcher_icons"], cwd=package, check=True) ios_project.write_bytes(kept) windows_icon = package / "windows/runner/resources/app_icon.ico" rounded(source, 256).save( windows_icon, format="ICO", sizes=[(size, size) for size in WINDOWS_SIZES] ) print(f"Wrote {windows_icon} with {len(WINDOWS_SIZES)} sizes.") def main() -> None: names = sys.argv[1:] or list(APPS) unknown = [name for name in names if name not in APPS] if unknown: raise SystemExit(f"No icon for {', '.join(unknown)}: {', '.join(APPS)}.") if not Path("tool/app_icons.py").is_file(): raise SystemExit("Run this from the repository's root.") license_text = fetch("LICENSE") for name in names: make(APPS[name], license_text) if __name__ == "__main__": main()