141 lines
4.8 KiB
Swift
141 lines
4.8 KiB
Swift
#!/usr/bin/env swift
|
|
import AppKit
|
|
import Foundation
|
|
|
|
// Renders AppIcon.icns from code — no design tool, no binary asset to keep in sync.
|
|
// Run: swift Tools/makeicon.swift
|
|
|
|
// Edison Voice: an amber lightbulb on a deep slate tile.
|
|
let background = NSColor(srgbRed: 0.13, green: 0.14, blue: 0.17, alpha: 1)
|
|
let bulbColor = NSColor(srgbRed: 1.00, green: 0.78, blue: 0.28, alpha: 1)
|
|
|
|
func drawIcon(size: CGFloat) -> NSImage {
|
|
let image = NSImage(size: NSSize(width: size, height: size))
|
|
image.lockFocus()
|
|
guard let ctx = NSGraphicsContext.current?.cgContext else {
|
|
image.unlockFocus()
|
|
return image
|
|
}
|
|
ctx.setShouldAntialias(true)
|
|
ctx.interpolationQuality = .high
|
|
|
|
// macOS Big Sur+ icon grid: art occupies the middle ~82%, leaving the shadow gutter.
|
|
let inset = size * 0.09
|
|
let rect = CGRect(x: inset, y: inset, width: size - inset * 2, height: size - inset * 2)
|
|
// Apple's squircle is ~22.37% of the tile's edge.
|
|
let radius = rect.width * 0.2237
|
|
let squircle = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
|
|
|
|
// Drop shadow under the tile.
|
|
ctx.saveGState()
|
|
ctx.setShadow(
|
|
offset: CGSize(width: 0, height: -size * 0.012),
|
|
blur: size * 0.035,
|
|
color: NSColor.black.withAlphaComponent(0.32).cgColor
|
|
)
|
|
ctx.addPath(squircle)
|
|
ctx.setFillColor(background.cgColor)
|
|
ctx.fillPath()
|
|
ctx.restoreGState()
|
|
|
|
// Soft top highlight so the tile reads as glass rather than flat fill.
|
|
ctx.saveGState()
|
|
ctx.addPath(squircle)
|
|
ctx.clip()
|
|
let highlight = CGGradient(
|
|
colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
|
colors: [
|
|
NSColor.white.withAlphaComponent(0.10).cgColor,
|
|
NSColor.white.withAlphaComponent(0.0).cgColor,
|
|
] as CFArray,
|
|
locations: [0, 1]
|
|
)!
|
|
ctx.drawLinearGradient(
|
|
highlight,
|
|
start: CGPoint(x: rect.midX, y: rect.maxY),
|
|
end: CGPoint(x: rect.midX, y: rect.midY),
|
|
options: []
|
|
)
|
|
ctx.restoreGState()
|
|
|
|
// Lightbulb glyph, tinted amber.
|
|
guard let base = NSImage(systemSymbolName: "lightbulb.fill", accessibilityDescription: nil) else {
|
|
image.unlockFocus()
|
|
return image
|
|
}
|
|
let pointSize = rect.width * 0.52
|
|
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold)
|
|
.applying(NSImage.SymbolConfiguration(hierarchicalColor: bulbColor))
|
|
let bulb = base.withSymbolConfiguration(config) ?? base
|
|
|
|
// Center the glyph. SF Symbols report their baseline-aware size, so frame it by its own
|
|
// intrinsic size rather than assuming a square.
|
|
let glyphSize = bulb.size
|
|
let drawRect = CGRect(
|
|
x: rect.midX - glyphSize.width / 2,
|
|
y: rect.midY - glyphSize.height / 2,
|
|
width: glyphSize.width,
|
|
height: glyphSize.height
|
|
)
|
|
|
|
ctx.saveGState()
|
|
ctx.setShadow(
|
|
offset: CGSize(width: 0, height: -size * 0.006),
|
|
blur: size * 0.02,
|
|
color: NSColor.black.withAlphaComponent(0.30).cgColor
|
|
)
|
|
bulb.draw(in: drawRect)
|
|
ctx.restoreGState()
|
|
|
|
image.unlockFocus()
|
|
return image
|
|
}
|
|
|
|
func png(_ image: NSImage, pixels: Int) -> Data? {
|
|
guard let rep = NSBitmapImageRep(
|
|
bitmapDataPlanes: nil,
|
|
pixelsWide: pixels, pixelsHigh: pixels,
|
|
bitsPerSample: 8, samplesPerPixel: 4,
|
|
hasAlpha: true, isPlanar: false,
|
|
colorSpaceName: .deviceRGB,
|
|
bytesPerRow: 0, bitsPerPixel: 0
|
|
) else { return nil }
|
|
rep.size = NSSize(width: pixels, height: pixels)
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
|
// Redraw at native pixel size rather than scaling a single render — keeps the small
|
|
// sizes crisp instead of muddy.
|
|
drawIcon(size: CGFloat(pixels)).draw(
|
|
in: NSRect(x: 0, y: 0, width: pixels, height: pixels),
|
|
from: .zero, operation: .sourceOver, fraction: 1
|
|
)
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
|
|
return rep.representation(using: .png, properties: [:])
|
|
}
|
|
|
|
let fm = FileManager.default
|
|
let root = URL(fileURLWithPath: fm.currentDirectoryPath)
|
|
let iconset = root.appendingPathComponent("Resources/AppIcon.iconset")
|
|
try? fm.removeItem(at: iconset)
|
|
try fm.createDirectory(at: iconset, withIntermediateDirectories: true)
|
|
|
|
// (point size, scale) pairs iconutil expects.
|
|
let variants: [(Int, Int)] = [
|
|
(16, 1), (16, 2), (32, 1), (32, 2), (128, 1), (128, 2),
|
|
(256, 1), (256, 2), (512, 1), (512, 2),
|
|
]
|
|
|
|
for (points, scale) in variants {
|
|
let pixels = points * scale
|
|
guard let data = png(NSImage(), pixels: pixels) else {
|
|
print("failed at \(pixels)px"); exit(1)
|
|
}
|
|
let suffix = scale == 2 ? "@2x" : ""
|
|
let name = "icon_\(points)x\(points)\(suffix).png"
|
|
try data.write(to: iconset.appendingPathComponent(name))
|
|
}
|
|
|
|
print("wrote \(variants.count) PNGs to Resources/AppIcon.iconset")
|