* Add dark mode app icon variant for macOS Sequoia Adds dark appearance entries to the AppIcon asset catalog so macOS 15+ automatically shows a dark-background icon when the system is in dark mode. The chevron gradient and glow are preserved by recompositing the foreground over a dark background (#1C1C1E). Includes a generation script (scripts/generate_dark_icon.py) that derives the dark PNGs from the light originals. * Add icon picker in Settings and fix dark icon quality Use the Figma chevron layer (design/cmux-icon-chevron.png) composited over a dark background for pixel-perfect results, no white halo or darkened gradient. Falls back to mathematical recomposition if the Figma layer is missing. Add an "App Icon" picker to Settings (under Theme) with three visual options: Automatic (follows system appearance via asset catalog dark variants on macOS 15+), Light, and Dark. The selection persists via UserDefaults and is applied on launch in AppDelegate.ensureApplicationIcon. * Fix dark icon chevron scale to match light icon The Figma export was ~25% larger than the repo icon. Scale the Figma chevron layer by 0.80x before compositing so the chevron size matches exactly between light and dark variants. * Use enhanced glow for dark icon Add a soft blue bloom around the chevron on the dark background using two Gaussian blur passes (wide at r=25 and tight at r=12) composited at reduced opacity beneath the sharp chevron. Makes the icon pop more against the dark squircle.
229 lines
7.4 KiB
Python
Executable file
229 lines
7.4 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Generate dark mode app icon variants.
|
|
|
|
Composites the Figma chevron layer (on transparent background) over a dark
|
|
squircle background derived from the light icon's alpha channel. This
|
|
preserves the exact chevron colors and glow without any halo artifacts.
|
|
|
|
Requires the Figma export at: design/cmux-icon-chevron.png
|
|
Falls back to mathematical recomposition if the Figma layer is missing.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
from PIL import Image, ImageFilter
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
# Apple systemBackground dark
|
|
DARK_BG = (28, 28, 30)
|
|
|
|
# Figma chevron layer (exported from Figma at native resolution)
|
|
FIGMA_CHEVRON = os.path.join(REPO, "design", "cmux-icon-chevron.png")
|
|
|
|
# The Figma export is ~25% larger than the repo icon. Scale and offset
|
|
# computed by matching the solid chevron (sat>0.5) bounding box center
|
|
# between the repo light icon and the scaled Figma chevron layer.
|
|
FIGMA_SCALE = 0.7996
|
|
FIGMA_OFFSET = (290, 187)
|
|
|
|
SIZES = [
|
|
("16.png", 16),
|
|
("16@2x.png", 32),
|
|
("32.png", 32),
|
|
("32@2x.png", 64),
|
|
("128.png", 128),
|
|
("128@2x.png", 256),
|
|
("256.png", 256),
|
|
("256@2x.png", 512),
|
|
("512.png", 512),
|
|
("512@2x.png", 1024),
|
|
]
|
|
|
|
|
|
def make_dark_from_figma(light_1024: Image.Image, chevron: Image.Image) -> Image.Image:
|
|
"""Create dark icon by compositing Figma chevron over dark background.
|
|
|
|
Uses the light icon's alpha channel for the squircle shape mask,
|
|
fills it with the dark background color, then composites the
|
|
chevron layer on top at the precomputed offset.
|
|
"""
|
|
size = 1024
|
|
light = light_1024.convert("RGBA")
|
|
if light.size != (size, size):
|
|
light = light.resize((size, size), Image.LANCZOS)
|
|
|
|
# Create dark background with the squircle shape from the light icon
|
|
dark_bg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
light_px = light.load()
|
|
dark_px = dark_bg.load()
|
|
for y in range(size):
|
|
for x in range(size):
|
|
_, _, _, a = light_px[x, y]
|
|
if a > 0:
|
|
dark_px[x, y] = (DARK_BG[0], DARK_BG[1], DARK_BG[2], a)
|
|
|
|
# Scale chevron
|
|
chev = chevron.convert("RGBA")
|
|
cw, ch = chev.size
|
|
scaled_w = int(cw * FIGMA_SCALE)
|
|
scaled_h = int(ch * FIGMA_SCALE)
|
|
chev = chev.resize((scaled_w, scaled_h), Image.LANCZOS)
|
|
ox, oy = FIGMA_OFFSET
|
|
|
|
# Build enhanced glow: brighten the chevron, blur at two radii
|
|
glow_src = chev.copy()
|
|
glow_px = glow_src.load()
|
|
for y in range(scaled_h):
|
|
for x in range(scaled_w):
|
|
r, g, b, a = glow_px[x, y]
|
|
if a > 0:
|
|
glow_px[x, y] = (
|
|
min(255, int(r * 1.2)),
|
|
min(255, int(g * 1.2)),
|
|
min(255, int(b * 1.2)),
|
|
min(255, int(a * 1.1)),
|
|
)
|
|
|
|
glow_canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
glow_canvas.paste(glow_src, (ox, oy), glow_src)
|
|
|
|
# Wide soft glow + tighter glow
|
|
glow_wide = glow_canvas.filter(ImageFilter.GaussianBlur(radius=25))
|
|
glow_tight = glow_canvas.filter(ImageFilter.GaussianBlur(radius=12))
|
|
|
|
# Reduce glow opacity
|
|
for glow, factor in [(glow_wide, 0.45), (glow_tight, 0.35)]:
|
|
gpx = glow.load()
|
|
for y in range(size):
|
|
for x in range(size):
|
|
r, g, b, a = gpx[x, y]
|
|
gpx[x, y] = (r, g, b, int(a * factor))
|
|
|
|
# Composite: dark bg -> wide glow -> tight glow -> sharp chevron
|
|
result = Image.alpha_composite(dark_bg, glow_wide)
|
|
result = Image.alpha_composite(result, glow_tight)
|
|
chev_canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
chev_canvas.paste(chev, (ox, oy), chev)
|
|
result = Image.alpha_composite(result, chev_canvas)
|
|
|
|
return result
|
|
|
|
|
|
def make_dark_fallback(img: Image.Image) -> Image.Image:
|
|
"""Fallback: mathematical recomposition when Figma layer is unavailable."""
|
|
img = img.convert("RGBA")
|
|
w, h = img.size
|
|
pixels = img.load()
|
|
|
|
for y in range(h):
|
|
for x in range(w):
|
|
r, g, b, a = pixels[x, y]
|
|
if a == 0:
|
|
continue
|
|
max_dev = max(255 - r, 255 - g, 255 - b)
|
|
fg_alpha = min(max_dev / 60.0, 1.0)
|
|
bg_factor = 1.0 - fg_alpha
|
|
nr = max(0, int(r - bg_factor * (255 - DARK_BG[0])))
|
|
ng = max(0, int(g - bg_factor * (255 - DARK_BG[1])))
|
|
nb = max(0, int(b - bg_factor * (255 - DARK_BG[2])))
|
|
pixels[x, y] = (nr, ng, nb, a)
|
|
|
|
return img
|
|
|
|
|
|
def update_contents_json(icon_dir: str) -> None:
|
|
"""Add dark appearance entries to Contents.json."""
|
|
contents_path = os.path.join(icon_dir, "Contents.json")
|
|
with open(contents_path) as f:
|
|
contents = json.load(f)
|
|
|
|
# Remove any existing dark entries to avoid duplicates
|
|
images = [
|
|
img for img in contents["images"]
|
|
if not any(
|
|
ap.get("value") == "dark"
|
|
for ap in img.get("appearances", [])
|
|
)
|
|
]
|
|
|
|
dark_images = []
|
|
for img in images:
|
|
filename = img.get("filename", "")
|
|
if not filename:
|
|
continue
|
|
base, ext = os.path.splitext(filename)
|
|
dark_entry = {
|
|
"appearances": [
|
|
{"appearance": "luminosity", "value": "dark"}
|
|
],
|
|
"filename": f"{base}_dark{ext}",
|
|
"idiom": img["idiom"],
|
|
"scale": img["scale"],
|
|
"size": img["size"],
|
|
}
|
|
dark_images.append(dark_entry)
|
|
|
|
# Interleave: light, dark, light, dark, ...
|
|
merged = []
|
|
for i, img in enumerate(images):
|
|
merged.append(img)
|
|
if i < len(dark_images):
|
|
merged.append(dark_images[i])
|
|
|
|
contents["images"] = merged
|
|
with open(contents_path, "w") as f:
|
|
json.dump(contents, f, indent=2)
|
|
f.write("\n")
|
|
print(f" Updated {contents_path}")
|
|
|
|
|
|
def generate_dark_icons(icon_set: str) -> None:
|
|
"""Generate dark variants for an icon set."""
|
|
src_dir = os.path.join(REPO, "Assets.xcassets", f"{icon_set}.appiconset")
|
|
if not os.path.isdir(src_dir):
|
|
print(f"SKIP {icon_set} (not found)")
|
|
return
|
|
|
|
use_figma = os.path.exists(FIGMA_CHEVRON)
|
|
if use_figma:
|
|
print(f"\n{icon_set} (using Figma chevron layer):")
|
|
chevron = Image.open(FIGMA_CHEVRON)
|
|
light_1024_path = os.path.join(src_dir, "512@2x.png")
|
|
light_1024 = Image.open(light_1024_path)
|
|
dark_1024 = make_dark_from_figma(light_1024, chevron)
|
|
else:
|
|
print(f"\n{icon_set} (fallback: mathematical recomposition):")
|
|
dark_1024 = None
|
|
|
|
for filename, pixel_size in SIZES:
|
|
src_path = os.path.join(src_dir, filename)
|
|
if not os.path.exists(src_path):
|
|
print(f" SKIP {filename} (not found)")
|
|
continue
|
|
|
|
base, ext = os.path.splitext(filename)
|
|
dst_path = os.path.join(src_dir, f"{base}_dark{ext}")
|
|
|
|
if use_figma:
|
|
# Downscale the 1024x1024 Figma composite
|
|
dark_img = dark_1024.resize((pixel_size, pixel_size), Image.LANCZOS)
|
|
else:
|
|
img = Image.open(src_path)
|
|
if img.size != (pixel_size, pixel_size):
|
|
img = img.resize((pixel_size, pixel_size), Image.LANCZOS)
|
|
dark_img = make_dark_fallback(img)
|
|
|
|
dark_img.save(dst_path, "PNG")
|
|
print(f" {base}_dark{ext} ({pixel_size}x{pixel_size})")
|
|
|
|
update_contents_json(src_dir)
|
|
|
|
|
|
def main():
|
|
generate_dark_icons("AppIcon")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|