tv-anarchy/tools/make-icon.swift
2026-06-09 05:50:20 -07:00

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 16512).
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)")
}