179 lines
7.1 KiB
Swift
179 lines
7.1 KiB
Swift
#!/usr/bin/env swift
|
|
// Renders the TVAnarchy app icon at every size the macOS asset catalog needs,
|
|
// plus a standalone 1024 master, using only CoreGraphics (no external deps).
|
|
//
|
|
// Concept: a sleek black television showing a glowing crimson anarchist
|
|
// circle-A. TV (rounded screen, bezel, antennae, faint CRT scanlines) +
|
|
// Anarchy (the overshooting circle-A). The circle-A is the hero so the mark
|
|
// stays legible at 16px when the finer TV details drop out.
|
|
//
|
|
// Usage: swift tools/make-icon.swift <output-dir>
|
|
// writes icon_<px>.png for each size into <output-dir>.
|
|
|
|
import AppKit
|
|
import CoreGraphics
|
|
import Foundation
|
|
|
|
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "."
|
|
|
|
// Unique pixel sizes referenced by the macOS AppIcon set (1x/2x of 16…512).
|
|
let sizes = [16, 32, 64, 128, 256, 512, 1024]
|
|
|
|
func color(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, _ a: CGFloat = 1) -> CGColor {
|
|
CGColor(red: r/255, green: g/255, blue: b/255, alpha: a)
|
|
}
|
|
|
|
let crimson = color(255, 38, 71) // hero red
|
|
let crimsonDeep = color(190, 18, 45)
|
|
let screenTop = color(28, 28, 32) // TV body / screen, dark charcoal
|
|
let screenBottom = color(6, 6, 8)
|
|
let bezel = color(44, 44, 50)
|
|
let scanline = color(120, 200, 255, 0.05)
|
|
|
|
func renderIcon(px: Int) -> CGImage {
|
|
let s = CGFloat(px)
|
|
let cs = CGColorSpaceCreateDeviceRGB()
|
|
let ctx = CGContext(data: nil, width: px, height: px, bitsPerComponent: 8,
|
|
bytesPerRow: 0, space: cs,
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
|
|
ctx.setAllowsAntialiasing(true)
|
|
ctx.interpolationQuality = .high
|
|
|
|
// Detail fades out at tiny sizes so the icon doesn't turn to mud.
|
|
let fine = s >= 64
|
|
|
|
// --- rounded-rect TV body (the macOS "squircle" footprint) -----------
|
|
let inset = s * 0.085
|
|
let bodyRect = CGRect(x: inset, y: inset, width: s - inset*2, height: s - inset*2)
|
|
let corner = s * 0.225
|
|
let bodyPath = CGPath(roundedRect: bodyRect, cornerWidth: corner, cornerHeight: corner, transform: nil)
|
|
|
|
// Drop shadow under the body for depth (skip on the smallest tiles).
|
|
if fine {
|
|
ctx.saveGState()
|
|
ctx.setShadow(offset: CGSize(width: 0, height: -s*0.012), blur: s*0.04,
|
|
color: color(0, 0, 0, 0.55))
|
|
ctx.addPath(bodyPath); ctx.setFillColor(screenBottom); ctx.fillPath()
|
|
ctx.restoreGState()
|
|
}
|
|
|
|
// Body gradient (top-lit charcoal → near-black).
|
|
ctx.saveGState()
|
|
ctx.addPath(bodyPath); ctx.clip()
|
|
let grad = CGGradient(colorsSpace: cs, colors: [screenTop, screenBottom] as CFArray,
|
|
locations: [0, 1])!
|
|
ctx.drawLinearGradient(grad, start: CGPoint(x: 0, y: s), end: CGPoint(x: 0, y: 0), options: [])
|
|
|
|
// Faint CRT scanlines across the screen.
|
|
if fine {
|
|
ctx.setFillColor(scanline)
|
|
let step = max(s * 0.018, 2)
|
|
var y = bodyRect.minY
|
|
while y < bodyRect.maxY {
|
|
ctx.fill(CGRect(x: bodyRect.minX, y: y, width: bodyRect.width, height: step*0.45))
|
|
y += step
|
|
}
|
|
// Soft screen vignette / glow toward center.
|
|
let glow = CGGradient(colorsSpace: cs,
|
|
colors: [color(255, 38, 71, 0.16), color(255, 38, 71, 0)] as CFArray,
|
|
locations: [0, 1])!
|
|
ctx.drawRadialGradient(glow,
|
|
startCenter: CGPoint(x: s/2, y: s*0.52), startRadius: 0,
|
|
endCenter: CGPoint(x: s/2, y: s*0.52), endRadius: s*0.42, options: [])
|
|
}
|
|
ctx.restoreGState()
|
|
|
|
// Inner bezel stroke (screen edge).
|
|
if fine {
|
|
ctx.addPath(bodyPath)
|
|
ctx.setStrokeColor(bezel)
|
|
ctx.setLineWidth(s * 0.012)
|
|
ctx.strokePath()
|
|
}
|
|
|
|
// --- antennae (two thin rods rising in a V from behind the body) -----
|
|
if fine {
|
|
let apexL = CGPoint(x: s*0.40, y: s*0.985)
|
|
let apexR = CGPoint(x: s*0.60, y: s*0.985)
|
|
let base = CGPoint(x: s*0.50, y: bodyRect.maxY - s*0.01)
|
|
ctx.setStrokeColor(bezel)
|
|
ctx.setLineWidth(s * 0.018)
|
|
ctx.setLineCap(.round)
|
|
ctx.beginPath(); ctx.move(to: base); ctx.addLine(to: apexL); ctx.strokePath()
|
|
ctx.beginPath(); ctx.move(to: base); ctx.addLine(to: apexR); ctx.strokePath()
|
|
for p in [apexL, apexR] {
|
|
ctx.setFillColor(crimson)
|
|
ctx.fillEllipse(in: CGRect(x: p.x - s*0.018, y: p.y - s*0.018, width: s*0.036, height: s*0.036))
|
|
}
|
|
}
|
|
|
|
// --- anarchist circle-A (hero mark) ----------------------------------
|
|
let cx = s * 0.5, cy = s * 0.5
|
|
let R = s * 0.255 // ring radius
|
|
let lw = s * 0.062 // stroke weight
|
|
|
|
// Glow halo behind the mark.
|
|
if fine {
|
|
ctx.saveGState()
|
|
ctx.setShadow(offset: .zero, blur: s*0.05, color: color(255, 38, 71, 0.9))
|
|
ctx.setStrokeColor(crimson)
|
|
ctx.setLineWidth(lw)
|
|
ctx.addArc(center: CGPoint(x: cx, y: cy), radius: R, startAngle: 0, endAngle: .pi*2, clockwise: false)
|
|
ctx.strokePath()
|
|
ctx.restoreGState()
|
|
}
|
|
|
|
// The ring.
|
|
let ringGrad = fine
|
|
ctx.setLineWidth(lw)
|
|
ctx.setLineCap(.butt)
|
|
if ringGrad {
|
|
ctx.saveGState()
|
|
ctx.addArc(center: CGPoint(x: cx, y: cy), radius: R, startAngle: 0, endAngle: .pi*2, clockwise: false)
|
|
ctx.setLineWidth(lw); ctx.replacePathWithStrokedPath(); ctx.clip()
|
|
let g = CGGradient(colorsSpace: cs, colors: [crimson, crimsonDeep] as CFArray, locations: [0, 1])!
|
|
ctx.drawLinearGradient(g, start: CGPoint(x: 0, y: cy+R), end: CGPoint(x: 0, y: cy-R), options: [])
|
|
ctx.restoreGState()
|
|
} else {
|
|
ctx.setStrokeColor(crimson)
|
|
ctx.addArc(center: CGPoint(x: cx, y: cy), radius: R, startAngle: 0, endAngle: .pi*2, clockwise: false)
|
|
ctx.strokePath()
|
|
}
|
|
|
|
// The "A": two legs from an apex, plus the crossbar that overshoots the
|
|
// ring on both sides (the defining feature of the anarchy symbol).
|
|
let apexY = cy + R * 0.92
|
|
let footY = cy - R * 0.86
|
|
let halfW = R * 0.62
|
|
let apex = CGPoint(x: cx, y: apexY)
|
|
let footL = CGPoint(x: cx - halfW, y: footY)
|
|
let footR = CGPoint(x: cx + halfW, y: footY)
|
|
|
|
ctx.setStrokeColor(crimson)
|
|
ctx.setLineWidth(lw)
|
|
ctx.setLineCap(.round)
|
|
ctx.setLineJoin(.round)
|
|
// Legs.
|
|
ctx.beginPath(); ctx.move(to: footL); ctx.addLine(to: apex); ctx.addLine(to: footR); ctx.strokePath()
|
|
// Overshooting crossbar.
|
|
let barY = cy - R * 0.04
|
|
let over = R * (fine ? 0.34 : 0.22)
|
|
ctx.beginPath()
|
|
ctx.move(to: CGPoint(x: cx - halfW*0.52 - over, y: barY))
|
|
ctx.addLine(to: CGPoint(x: cx + halfW*0.52 + over, y: barY))
|
|
ctx.strokePath()
|
|
|
|
return ctx.makeImage()!
|
|
}
|
|
|
|
for px in sizes {
|
|
let img = renderIcon(px: px)
|
|
let rep = NSBitmapImageRep(cgImage: img)
|
|
rep.size = NSSize(width: px, height: px)
|
|
guard let data = rep.representation(using: .png, properties: [:]) else {
|
|
FileHandle.standardError.write("failed to encode \(px)\n".data(using: .utf8)!); exit(1)
|
|
}
|
|
let url = URL(fileURLWithPath: outDir).appendingPathComponent("icon_\(px).png")
|
|
try! data.write(to: url)
|
|
print("wrote \(url.lastPathComponent)")
|
|
}
|