Add dark mode app icon for macOS Sequoia (#702)
* 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.
BIN
Assets.xcassets/AppIcon.appiconset/128@2x_dark.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/128_dark.png
Normal file
|
After Width: | Height: | Size: 6 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/16@2x_dark.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/16_dark.png
Normal file
|
After Width: | Height: | Size: 555 B |
BIN
Assets.xcassets/AppIcon.appiconset/256@2x_dark.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/256_dark.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/32@2x_dark.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/32_dark.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/512@2x_dark.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/512_dark.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
|
|
@ -1,68 +1,188 @@
|
|||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"filename" : "16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
"filename": "16.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "16_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
"filename": "16@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "16@2x_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
"filename": "32.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "32_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
"filename": "32@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "32@2x_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
"filename": "128.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "128_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "128x128"
|
||||
},
|
||||
{
|
||||
"filename": "128@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "128x128"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "128@2x_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "128x128"
|
||||
},
|
||||
{
|
||||
"filename": "256.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "256x256"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "256_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "256x256"
|
||||
},
|
||||
{
|
||||
"filename": "256@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "256x256"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "256@2x_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "256x256"
|
||||
},
|
||||
{
|
||||
"filename": "512.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "512x512"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "512_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "512x512"
|
||||
},
|
||||
{
|
||||
"filename": "512@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "512x512"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "512@2x_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
Assets.xcassets/AppIconDark.imageset/AppIconDark.png
vendored
Normal file
|
After Width: | Height: | Size: 145 KiB |
12
Assets.xcassets/AppIconDark.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIconDark.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Assets.xcassets/AppIconLight.imageset/AppIconLight.png
vendored
Normal file
|
After Width: | Height: | Size: 198 KiB |
12
Assets.xcassets/AppIconLight.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIconLight.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -6249,8 +6249,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
|
||||
private func ensureApplicationIcon() {
|
||||
if let icon = NSImage(named: NSImage.applicationIconName) {
|
||||
NSApplication.shared.applicationIconImage = icon
|
||||
let mode = AppIconSettings.resolvedMode()
|
||||
if mode == .automatic {
|
||||
// Let the asset catalog handle appearance-based icon selection.
|
||||
if let icon = NSImage(named: NSImage.applicationIconName) {
|
||||
NSApplication.shared.applicationIconImage = icon
|
||||
}
|
||||
} else {
|
||||
AppIconSettings.applyIcon(mode)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2601,6 +2601,60 @@ enum AppearanceSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum AppIconMode: String, CaseIterable, Identifiable {
|
||||
case automatic
|
||||
case light
|
||||
case dark
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .automatic: return "Automatic"
|
||||
case .light: return "Light"
|
||||
case .dark: return "Dark"
|
||||
}
|
||||
}
|
||||
|
||||
var imageName: String? {
|
||||
switch self {
|
||||
case .automatic: return nil
|
||||
case .light: return "AppIconLight"
|
||||
case .dark: return "AppIconDark"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppIconSettings {
|
||||
static let modeKey = "appIconMode"
|
||||
static let defaultMode: AppIconMode = .automatic
|
||||
|
||||
static func resolvedMode(defaults: UserDefaults = .standard) -> AppIconMode {
|
||||
guard let raw = defaults.string(forKey: modeKey),
|
||||
let mode = AppIconMode(rawValue: raw) else {
|
||||
return defaultMode
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
static func applyIcon(_ mode: AppIconMode) {
|
||||
switch mode {
|
||||
case .automatic:
|
||||
// Let the asset catalog handle appearance-based icon selection (macOS 15+).
|
||||
// Reset to the default bundle icon.
|
||||
NSApplication.shared.applicationIconImage = nil
|
||||
case .light:
|
||||
if let icon = NSImage(named: "AppIconLight") {
|
||||
NSApplication.shared.applicationIconImage = icon
|
||||
}
|
||||
case .dark:
|
||||
if let icon = NSImage(named: "AppIconDark") {
|
||||
NSApplication.shared.applicationIconImage = icon
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum QuitWarningSettings {
|
||||
static let warnBeforeQuitKey = "warnBeforeQuitShortcut"
|
||||
static let defaultWarnBeforeQuit = true
|
||||
|
|
@ -2661,6 +2715,7 @@ struct SettingsView: View {
|
|||
private let pickerColumnWidth: CGFloat = 196
|
||||
|
||||
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
|
||||
@AppStorage(AppIconSettings.modeKey) private var appIconMode = AppIconSettings.defaultMode.rawValue
|
||||
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
|
||||
@AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
||||
private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
|
||||
|
|
@ -2834,6 +2889,16 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
AppIconPickerRow(
|
||||
selectedMode: appIconMode,
|
||||
onSelect: { mode in
|
||||
appIconMode = mode.rawValue
|
||||
AppIconSettings.applyIcon(mode)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"New Workspace Placement",
|
||||
subtitle: selectedWorkspacePlacement.description,
|
||||
|
|
@ -3526,6 +3591,8 @@ struct SettingsView: View {
|
|||
|
||||
private func resetAllSettings() {
|
||||
appearanceMode = AppearanceSettings.defaultMode.rawValue
|
||||
appIconMode = AppIconSettings.defaultMode.rawValue
|
||||
AppIconSettings.applyIcon(.automatic)
|
||||
socketControlMode = SocketControlSettings.defaultMode.rawValue
|
||||
claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
|
||||
sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry
|
||||
|
|
@ -3740,6 +3807,79 @@ private struct SettingsCardNote: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct AppIconPickerRow: View {
|
||||
let selectedMode: String
|
||||
let onSelect: (AppIconMode) -> Void
|
||||
|
||||
private let iconSize: CGFloat = 48
|
||||
private let autoIconSize: CGFloat = 36
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("App Icon")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(AppIconMode.allCases) { mode in
|
||||
let isSelected = selectedMode == mode.rawValue
|
||||
Button {
|
||||
onSelect(mode)
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Group {
|
||||
if mode == .automatic {
|
||||
// Show both icons overlapping
|
||||
ZStack {
|
||||
Image("AppIconLight")
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.frame(width: autoIconSize, height: autoIconSize)
|
||||
.clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous))
|
||||
.offset(x: -10)
|
||||
Image("AppIconDark")
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.frame(width: autoIconSize, height: autoIconSize)
|
||||
.clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous))
|
||||
.offset(x: 10)
|
||||
}
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
} else {
|
||||
Image(mode.imageName ?? "AppIconLight")
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
.clipShape(RoundedRectangle(cornerRadius: iconSize * 0.22, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
Text(mode.displayName)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(isSelected ? .primary : .secondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(isSelected
|
||||
? Color.accentColor.opacity(0.12)
|
||||
: Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShortcutSettingRow: View {
|
||||
let action: KeyboardShortcutSettings.Action
|
||||
@State private var shortcut: StoredShortcut
|
||||
|
|
|
|||
BIN
design/cmux-icon-chevron.png
Normal file
|
After Width: | Height: | Size: 486 KiB |
229
scripts/generate_dark_icon.py
Executable file
|
|
@ -0,0 +1,229 @@
|
|||
#!/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()
|
||||