feat: the apps' icons as one family, drawn by one tool (ADR 0031)
tool/app_icons.py at the root replaces Learn's and Todo's scripts: each app's Lucide glyph (lucide-static 1.50.0) drawn by librsvg, fading from cobalt through the colours ADR 0031 names, then every platform's launcher icon through flutter_launcher_icons, Windows' .ico with every size, and Learn's snap icon. Learn's icon becomes the graduation cap, its window's on Linux too, with Lucide's notice in its licence list; the kingfisher stays the emblem on the rail and the launch screen. Todo's becomes list-todo, Train's bike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7b5fbee913
commit
ece9ce9697
1 changed files with 342 additions and 322 deletions
|
|
@ -1,196 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Makes Halkyon Todo's icons: Lucide's `list-checks` in the brand's blues (cobalt
|
||||
0xFF3C57E5 into sky 0xFF179FD9, top left to bottom right, as the first start's
|
||||
other tile draws its globe) on a white square, 1024 x 1024 pixels
|
||||
(halkyon_todo_flutter/assets/icon/halkyon_todo_icon.png); the launch screen and
|
||||
launchers round its corners themselves. Then, as Learn's tool/app_icons.py does:
|
||||
|
||||
- through flutter_launcher_icons, configured in the app's pubspec: Android's
|
||||
adaptive icon and its older square one, iOS's, macOS's (the rounded tile on
|
||||
Apple's grid, 824 of 1024 pixels) and Windows' (the rounded tile);
|
||||
- Windows' .ico once more, with every size Windows shows (16 to 256 pixels).
|
||||
|
||||
The icon comes from lucide-static at a pinned release (LUCIDE below), fetched from
|
||||
jsDelivr, with Lucide's licence (ISC), which the app registers in its licence list
|
||||
(halkyon_todo_flutter/assets/icon/LICENSE-lucide.txt). No SVG renderer is needed:
|
||||
the icon's paths are straight strokes, which this draws itself with Pillow, round at
|
||||
their ends and joins as Lucide's are; a path with other commands fails loudly.
|
||||
|
||||
Run from apps/todo with Python 3 and Pillow (python3-pil on Ubuntu), the app's
|
||||
dependencies fetched:
|
||||
|
||||
python3 -I tool/app_icons.py
|
||||
"""
|
||||
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
LUCIDE = "1.50.0"
|
||||
ICON = "list-checks"
|
||||
CDN = f"https://cdn.jsdelivr.net/npm/lucide-static@{LUCIDE}"
|
||||
|
||||
APP = Path("halkyon_todo_flutter")
|
||||
SOURCE = APP / "assets/icon/halkyon_todo_icon.png"
|
||||
LICENSE = APP / "assets/icon/LICENSE-lucide.txt"
|
||||
DERIVED = APP / "build/icons"
|
||||
WINDOWS_ICON = APP / "windows/runner/resources/app_icon.ico"
|
||||
IOS_PROJECT = APP / "ios/Runner.xcodeproj/project.pbxproj"
|
||||
|
||||
SIZE = 1024
|
||||
SUPERSAMPLING = 4
|
||||
# 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
|
||||
COBALT = (0x3C, 0x57, 0xE5)
|
||||
SKY = (0x17, 0x9F, 0xD9)
|
||||
# The corners the launch screen and launchers round to (Learn's).
|
||||
RADIUS = 142.222 / 512
|
||||
MACOS_CANVAS = 1024
|
||||
MACOS_TILE = 824
|
||||
WINDOWS_SIZES = [16, 20, 24, 32, 40, 48, 64, 128, 256]
|
||||
|
||||
|
||||
def fetch(name: str) -> str:
|
||||
with urllib.request.urlopen(f"{CDN}/{name}", timeout=30) as response:
|
||||
return response.read().decode("utf-8")
|
||||
|
||||
|
||||
def polylines(path: str) -> list[list[tuple[float, float]]]:
|
||||
"""The straight strokes of an SVG path's data, in its own units."""
|
||||
tokens = re.findall(r"[A-Za-z]|-?\d*\.?\d+(?:e-?\d+)?", path)
|
||||
lines: list[list[tuple[float, float]]] = []
|
||||
x = y = 0.0
|
||||
command = ""
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
if tokens[i].isalpha():
|
||||
command = tokens[i]
|
||||
i += 1
|
||||
if command in "Zz":
|
||||
lines[-1].append(lines[-1][0])
|
||||
continue
|
||||
if command in "Mm":
|
||||
dx, dy = float(tokens[i]), float(tokens[i + 1])
|
||||
i += 2
|
||||
x, y = (x + dx, y + dy) if command == "m" else (dx, dy)
|
||||
lines.append([(x, y)])
|
||||
# Further pairs after a move are lines to, as SVG has it.
|
||||
command = "l" if command == "m" else "L"
|
||||
elif command in "Ll":
|
||||
dx, dy = float(tokens[i]), float(tokens[i + 1])
|
||||
i += 2
|
||||
x, y = (x + dx, y + dy) if command == "l" else (dx, dy)
|
||||
lines[-1].append((x, y))
|
||||
elif command in "Hh":
|
||||
dx = float(tokens[i])
|
||||
i += 1
|
||||
x = x + dx if command == "h" else dx
|
||||
lines[-1].append((x, y))
|
||||
elif command in "Vv":
|
||||
dy = float(tokens[i])
|
||||
i += 1
|
||||
y = y + dy if command == "v" else dy
|
||||
lines[-1].append((x, y))
|
||||
else:
|
||||
raise SystemExit(f"{ICON}: the path command {command!r} isn't drawn here.")
|
||||
return lines
|
||||
|
||||
|
||||
def icon(svg: str) -> Image.Image:
|
||||
"""The icon on white, SIZE pixels square."""
|
||||
if f"lucide-static v{LUCIDE}" not in svg:
|
||||
raise SystemExit(f"Not lucide-static {LUCIDE}'s {ICON}.")
|
||||
if 'viewBox="0 0 24 24"' not in svg or 'stroke-width="2"' not in svg:
|
||||
raise SystemExit(f"{ICON} isn't drawn in Lucide's 24 units at width 2.")
|
||||
large = SIZE * SUPERSAMPLING
|
||||
unit = large * GLYPH / 24
|
||||
offset = large * (1 - GLYPH) / 2
|
||||
width = 2 * unit
|
||||
|
||||
mask = Image.new("L", (large, large), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
half = width / 2
|
||||
for data in re.findall(r'<path d="([^"]+)"', svg):
|
||||
for line in polylines(data):
|
||||
points = [(offset + px * unit, offset + py * unit) for px, py in line]
|
||||
# Each segment as the exact band around it, so its edges meet the
|
||||
# discs at its ends without a step.
|
||||
for (x0, y0), (x1, y1) in zip(points, points[1:]):
|
||||
length = math.hypot(x1 - x0, y1 - y0)
|
||||
nx, ny = -(y1 - y0) / length * half, (x1 - x0) / length * half
|
||||
draw.polygon(
|
||||
[(x0 + nx, y0 + ny), (x1 + nx, y1 + ny),
|
||||
(x1 - nx, y1 - ny), (x0 - nx, y0 - ny)],
|
||||
fill=255,
|
||||
)
|
||||
# Round ends and joins: a disc at every point.
|
||||
for cx, cy in points:
|
||||
draw.ellipse((cx - half, cy - half, cx + half, cy + half), fill=255)
|
||||
|
||||
# Cobalt into sky across Lucide's box, top left to bottom right: worked out
|
||||
# small and scaled up, which leaves a straight gradient as it is.
|
||||
small = 256
|
||||
gradient = Image.new("RGB", (small, small))
|
||||
pixels = gradient.load()
|
||||
start = small * (1 - GLYPH) / 2
|
||||
span = small * GLYPH * 2
|
||||
for gy in range(small):
|
||||
for gx in range(small):
|
||||
t = min(max(((gx - start) + (gy - start)) / span, 0.0), 1.0)
|
||||
pixels[gx, gy] = tuple(
|
||||
round(a + (b - a) * t) for a, b in zip(COBALT, SKY)
|
||||
)
|
||||
gradient = gradient.resize((large, large), Image.BILINEAR)
|
||||
|
||||
tile = Image.new("RGB", (large, large), (255, 255, 255))
|
||||
tile.paste(gradient, mask=mask)
|
||||
return tile.resize((SIZE, SIZE), Image.LANCZOS)
|
||||
|
||||
|
||||
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 main() -> None:
|
||||
svg = fetch(f"icons/{ICON}.svg")
|
||||
source = icon(svg)
|
||||
SOURCE.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.save(SOURCE, optimize=True)
|
||||
print(f"Wrote {SOURCE}.")
|
||||
LICENSE.write_text(fetch("LICENSE"), encoding="utf-8")
|
||||
print(f"Wrote {LICENSE}.")
|
||||
|
||||
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")
|
||||
|
||||
# flutter_launcher_icons rewrites the iOS project's settings; they stay as
|
||||
# they are.
|
||||
ios_project = IOS_PROJECT.read_bytes()
|
||||
subprocess.run(["dart", "run", "flutter_launcher_icons"], cwd=APP, check=True)
|
||||
IOS_PROJECT.write_bytes(ios_project)
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue