bugger/scripts/generate_app_icon.py

174 lines
5.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Generate Bugger app icon (bug beetle) in all required sizes for macOS."""
import math, os, shutil
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
ICONSET = ROOT / "Resources" / "Assets.xcassets" / "AppIcon.appiconset"
# Required macOS icon sizes: (logical_size, scale_factor, actual_pixels)
SIZES = [
(16, 1, 16), (16, 2, 32),
(32, 1, 32), (32, 2, 64),
(128, 1, 128), (128, 2, 256),
(256, 1, 256), (256, 2, 512),
(512, 1, 512), (512, 2, 1024),
]
def draw_bug(canvas_size: int) -> Image.Image:
"""Draw a bug beetle icon at given canvas size. Returns RGBA image."""
img = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
s = canvas_size # shorthand
# Colors
body_fill = (45, 50, 55, 255) # dark charcoal
body_highlight = (65, 72, 78, 255) # lighter shell
accent_green = (76, 188, 128, 255) # green accent
antenna = (45, 50, 55, 255)
leg = (45, 50, 55, 255)
eye = (255, 255, 255, 255)
# Margins (proportional)
margin = s * 0.12
# ---- Body (rounded oval) ----
body_rect = (
margin + s * 0.08, # left
margin + s * 0.22, # top
s - margin - s * 0.08, # right
s - margin + s * 0.05, # bottom
)
d.rounded_rectangle(body_rect, radius=s * 0.28, fill=body_fill)
# ---- Shell highlight (lighter arc on top half) ----
shell_rect = (
margin + s * 0.06,
margin + s * 0.22,
s - margin - s * 0.06,
s * 0.62,
)
d.pieslice(shell_rect, start=180, end=360, fill=body_highlight)
# ---- Green accent stripe ----
stripe_rect = (
s * 0.32,
s * 0.38,
s * 0.68,
s * 0.62,
)
d.rounded_rectangle(stripe_rect, radius=s * 0.06, fill=accent_green)
# ---- Head (small semi-circle at top) ----
head_center = (s * 0.50, s * 0.30)
head_r = s * 0.095
d.ellipse(
(head_center[0] - head_r, head_center[1] - head_r,
head_center[0] + head_r, head_center[1] + head_r),
fill=body_fill
)
# ---- Eyes ----
eye_r = s * 0.025
eye_offset = s * 0.04
for ex in (head_center[0] - eye_offset, head_center[0] + eye_offset):
d.ellipse(
(ex - eye_r, head_center[1] - eye_r - s * 0.01,
ex + eye_r, head_center[1] + eye_r - s * 0.01),
fill=eye
)
# ---- Antennae (two curved lines) ----
ant_line_w = max(2, int(s * 0.015))
ant_start_y = head_center[1] - head_r + s * 0.01
for side, curve_dir in [(-1, -0.08), (1, 0.08)]:
ant_start_x = head_center[0] + side * s * 0.04
points = [
(ant_start_x, ant_start_y),
(ant_start_x + side * s * 0.06, ant_start_y - s * 0.08),
(ant_start_x + side * s * 0.14, ant_start_y - s * 0.18 + curve_dir * s),
(ant_start_x + side * s * 0.18 + curve_dir * s * 0.5, ant_start_y - s * 0.17),
]
for i in range(len(points) - 1):
d.line([points[i], points[i + 1]], fill=antenna, width=ant_line_w)
# dot at tip
tip_r = max(1, int(s * 0.012))
tip = points[-1]
d.ellipse((tip[0] - tip_r, tip[1] - tip_r, tip[0] + tip_r, tip[1] + tip_r),
fill=antenna)
# ---- Legs (3 per side) ----
leg_w = max(2, int(s * 0.018))
leg_positions = [0.45, 0.56, 0.67] # y-positions as fraction of body height
body_left = margin + s * 0.02
body_right = s - margin - s * 0.02
for frac in leg_positions:
leg_y = s * (0.22 + frac * 0.57)
# Left legs
d.line(
[(body_left, leg_y),
(body_left - s * 0.16, leg_y - s * 0.08),
(body_left - s * 0.22, leg_y + s * 0.04)],
fill=leg, width=leg_w
)
# Right legs
d.line(
[(body_right, leg_y),
(body_right + s * 0.16, leg_y - s * 0.08),
(body_right + s * 0.22, leg_y + s * 0.04)],
fill=leg, width=leg_w
)
return img
def main():
ICONSET.mkdir(parents=True, exist_ok=True)
# Generate the largest size and downscale
master = draw_bug(1024)
images = []
for logical, scale, pixels in SIZES:
if pixels == 1024:
img = master
else:
img = master.resize((pixels, pixels), Image.LANCZOS)
filename = f"icon_{logical}x{logical}@x{scale}.png"
filepath = ICONSET / filename
img.save(filepath, "PNG")
images.append({
"size": f"{logical}x{logical}",
"scale": f"{scale}x",
"filename": filename,
})
print(f" {filename} ({pixels}×{pixels})")
# Update Contents.json
contents = {
"images": [],
"info": {"author": "xcode", "version": 1},
}
for img in images:
contents["images"].append({
"size": img["size"],
"idiom": "mac",
"filename": img["filename"],
"scale": img["scale"],
})
import json
contents_path = ICONSET / "Contents.json"
with open(contents_path, "w") as f:
json.dump(contents, f, indent=2)
print(f"\nWrote {contents_path}")
print(f"Generated {len(images)} icon sizes in {ICONSET}")
if __name__ == "__main__":
main()