summaryrefslogtreecommitdiff
path: root/src/macos/icon.swift
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos/icon.swift')
-rw-r--r--src/macos/icon.swift307
1 files changed, 307 insertions, 0 deletions
diff --git a/src/macos/icon.swift b/src/macos/icon.swift
new file mode 100644
index 00000000..2e775660
--- /dev/null
+++ b/src/macos/icon.swift
@@ -0,0 +1,307 @@
+// Draws pardes.app's icon at build time and hands the result to iconutil.
+//
+// The mark is GLENDA, the Plan 9 rabbit — pardes is an acme, and acme is
+// Plan 9's, so the bunny is the lineage stated in one shape. She is drawn out
+// of the terminal's own palette rather than traced from a bitmap: the ground
+// is defaultBG, the strip she sits under is the tag bar, and she herself is
+// defaultFG. That is also why this is generated instead of committed — a
+// checked-in .icns is a binary blob that stops matching the app the first time
+// one of those colours moves, silently, with nothing in a diff to catch it.
+//
+// build.zig compiles this file alone into a cached binary and runs it with the
+// bundle's Resources directory as argv[1]; Info.plist's CFBundleIconFile names
+// the pardes.icns that comes out. Compiled alone is also what makes top-level
+// code legal here: one file, one module, its own binary.
+//
+// Byte-identical output for byte-identical input is a requirement, not a
+// nicety — an icns that churns on every build is a bundle that churns on every
+// build, and Launch Services notices. Hence a pinned sRGB colour space, integer
+// geometry, and nothing read from the clock or the environment.
+
+import CoreGraphics
+import Foundation
+import ImageIO
+import UniformTypeIdentifiers
+
+/// Every failure path lands here. A build that ships the generic blank-page
+/// icon looks like an app nobody finished, so half-drawn is worse than absent
+/// and build.zig has this exit code waiting: a bundle whose icon did not draw
+/// should not have built.
+func die(_ message: String) -> Never {
+ fputs("icon.swift: \(message)\n", stderr)
+ exit(1)
+}
+
+struct RGB {
+ let red: CGFloat
+ let green: CGFloat
+ let blue: CGFloat
+
+ init(_ hex: UInt32) {
+ red = CGFloat((hex >> 16) & 0xFF) / 255
+ green = CGFloat((hex >> 8) & 0xFF) / 255
+ blue = CGFloat(hex & 0xFF) / 255
+ }
+
+ func components(_ alpha: CGFloat) -> [CGFloat] { [red, green, blue, alpha] }
+}
+
+// Straight out of PardesView.swift. If those move these move, because the icon
+// is a picture of the running program and a stale picture is worse than none:
+// it looks deliberate.
+let bodyTop = RGB(0x12_12_12) // defaultBG
+let bodyBottom = RGB(0x0A_0A_0A) // defaultBG, shaded
+let tagBar = RGB(0x34_65_A4) // ansi16[4], the muted blue
+let text = RGB(0xCC_CC_CC) // defaultFG
+let cursor = RGB(0xFC_E9_4F) // ansi16[11], bright yellow
+
+// Apple's icon grid rather than the whole square: the artwork is a rounded
+// square floating in a transparent margin, 824 of 1024 with a 185.4 corner
+// radius in the template — 80.47% of the canvas, and 22.37% of the SQUARE, not
+// of the canvas, which would over-round it by a quarter. Filling the canvas
+// edge to edge is the loudest tell that an app was not built on a Mac.
+let squareFraction: CGFloat = 0.8047
+let cornerFraction: CGFloat = 0.2237
+
+// GLENDA, as ellipses. Four for the silhouette, filled as ONE path so the
+// overlaps vanish under nonzero winding and she is a single shape rather than
+// four stuck together, then two eyes punched back out in the ground colour.
+//
+// Ellipses and not a traced outline for the reason everything else here is a
+// fraction: the mark has to survive being twelve pixels across. An outlined
+// drawing at that size is a grey smudge with a lighter grey inside it, whereas
+// a silhouette is still a rabbit — the two ears are the whole recognition, and
+// they are the two shapes that reach furthest from the mass.
+let tagHeight: CGFloat = 0.165
+
+/// Her box: the body square under the tag bar, inset so the ears are not
+/// welded to the strip and the haunch is not welded to the bottom corners.
+let stageTop: CGFloat = 0.250
+let stageBottom: CGFloat = 0.950
+let stageInset: CGFloat = 0.135
+
+/// One ellipse of her, in fractions of that box: centre, radii, and a tilt in
+/// degrees about its own centre. Fractions rather than points because the same
+/// numbers have to describe the mark at 16 pixels and at 1024.
+struct Blob {
+ let cx: CGFloat
+ let cy: CGFloat
+ let rx: CGFloat
+ let ry: CGFloat
+ let tilt: CGFloat
+
+ init(_ cx: CGFloat, _ cy: CGFloat, _ rx: CGFloat, _ ry: CGFloat, tilt: CGFloat = 0) {
+ self.cx = cx
+ self.cy = cy
+ self.rx = rx
+ self.ry = ry
+ self.tilt = tilt
+ }
+
+ func path(in stage: CGRect) -> CGPath {
+ let box = CGRect(
+ x: -stage.width * rx, y: -stage.height * ry,
+ width: stage.width * rx * 2, height: stage.height * ry * 2)
+ var placement = CGAffineTransform(
+ translationX: stage.minX + stage.width * cx,
+ y: stage.minY + stage.height * cy
+ ).rotated(by: tilt * .pi / 180)
+ return CGPath(ellipseIn: box, transform: &placement)
+ }
+}
+
+// The ears overlap the head and the head overlaps the haunch on purpose: each
+// pair has to still intersect after rounding at 16 pixels, or she comes apart
+// into floating pieces at exactly the size nobody would look twice at.
+let silhouette: [Blob] = [
+ Blob(0.325, 0.150, 0.080, 0.200, tilt: -12), // left ear
+ Blob(0.675, 0.150, 0.080, 0.200, tilt: 12), // right ear
+ Blob(0.500, 0.490, 0.245, 0.212), // head
+ Blob(0.500, 0.785, 0.268, 0.215), // haunch
+]
+
+// Set wide and low in the head, which is the whole of her expression. Rounder
+// than a dot and smaller than the classic drawing's, because a big oval eye
+// closes up into a grey blur two sizes down.
+let eyes: [Blob] = [
+ Blob(0.393, 0.468, 0.056, 0.070),
+ Blob(0.607, 0.468, 0.056, 0.070),
+]
+
+/// Wider than it is tall, sitting just under the eyes: the one shape that says
+/// rabbit rather than cat. Punched in the ground colour like the eyes.
+let nose = Blob(0.500, 0.605, 0.045, 0.030)
+
+// ...and the block cursor, parked at the end of the tag bar. The palette's
+// last entry, and the only warm thing in the icon: pardes is still an acme,
+// and this is the two pixels that say so above her head.
+let cursorWidth: CGFloat = 0.072
+let cursorRightPad: CGFloat = 0.120
+
+/// sRGB in the bitmap and sRGB in every colour put into it. `setFillColor(red:
+/// green:blue:alpha:)` speaks DeviceRGB, which is a colour match on the way in,
+/// and #121212 would stop being #121212.
+func sRGB() -> CGColorSpace {
+ guard let space = CGColorSpace(name: CGColorSpace.sRGB) else { die("sRGB colour space unavailable") }
+ return space
+}
+
+func cgColor(_ rgb: RGB, alpha: CGFloat = 1) -> CGColor {
+ guard let color = CGColor(colorSpace: sRGB(), components: rgb.components(alpha)) else {
+ die("CGColor from sRGB components failed")
+ }
+ return color
+}
+
+func renderIcon(pixels: Int) -> CGImage {
+ guard
+ let ctx = CGContext(
+ data: nil, width: pixels, height: pixels,
+ bitsPerComponent: 8, bytesPerRow: 0, space: sRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
+ else { die("CGContext \(pixels)x\(pixels) failed") }
+
+ // Top-left origin, so the constants above read in the order the picture
+ // does. The scale stays ±1, which is what lets snap() round in user space.
+ ctx.translateBy(x: 0, y: CGFloat(pixels))
+ ctx.scaleBy(x: 1, y: -1)
+
+ // Round the MARGIN and derive the square from it. Rounding the square
+ // instead leaves an odd remainder to split, and at 16 pixels the artwork
+ // lands a pixel off centre. At 1024 this is Apple's 100/824/100 exactly.
+ let canvas = CGFloat(pixels)
+ let inset = (canvas * (1 - squareFraction) / 2).rounded()
+ let side = canvas - 2 * inset
+ let body = CGRect(x: inset, y: inset, width: side, height: side)
+ let corner = side * cornerFraction
+
+ ctx.saveGState()
+ ctx.addPath(CGPath(roundedRect: body, cornerWidth: corner, cornerHeight: corner, transform: nil))
+ ctx.clip()
+
+ // The only gradient in the icon, and it earns its place: a flat near-black
+ // square reads as a hole punched in the Dock rather than as an object.
+ let stops = bodyTop.components(1) + bodyBottom.components(1)
+ let locations: [CGFloat] = [0, 1]
+ guard
+ let gradient = CGGradient(
+ colorSpace: sRGB(), colorComponents: stops, locations: locations, count: 2)
+ else { die("CGGradient failed") }
+ ctx.drawLinearGradient(
+ gradient,
+ start: CGPoint(x: body.midX, y: body.minY),
+ end: CGPoint(x: body.midX, y: body.maxY),
+ options: [])
+
+ // Full bleed, and still inside the clip so its top corners round with the
+ // body. src/pardes.zig fills row 0 across the whole width the same way;
+ // that strip is the silhouette of an acme screen and it is the one thing
+ // that has to survive being two pixels tall.
+ ctx.setFillColor(cgColor(tagBar))
+ let tagRect = CGRect(
+ x: body.minX, y: body.minY,
+ width: side, height: max(1, (side * tagHeight).rounded()))
+ ctx.fill(tagRect)
+
+ // The block cursor at the end of it. Inside the clip and inset from the
+ // corner so the rounding never clips a corner off the block itself.
+ ctx.setFillColor(cgColor(cursor))
+ ctx.fill(
+ CGRect(
+ x: (body.maxX - side * (cursorRightPad + cursorWidth)).rounded(),
+ y: (tagRect.minY + tagRect.height * 0.24).rounded(),
+ width: max(1, (side * cursorWidth).rounded()),
+ height: max(1, (tagRect.height * 0.52).rounded())))
+ ctx.restoreGState()
+
+ // Glenda. One fill for the whole silhouette so the four ellipses union
+ // instead of seaming, then the eyes and the nose over the top of her.
+ let stage = CGRect(
+ x: body.minX + side * stageInset,
+ y: body.minY + side * stageTop,
+ width: side * (1 - 2 * stageInset),
+ height: side * (stageBottom - stageTop))
+
+ ctx.setFillColor(cgColor(text))
+ for blob in silhouette { ctx.addPath(blob.path(in: stage)) }
+ ctx.fillPath(using: .winding)
+
+ // The ground colour rather than black: her eyes and nose are HOLES in her,
+ // and a hole darker than what is behind it reads as paint. One fill for all
+ // three, so they can never disagree about which colour a hole is.
+ ctx.setFillColor(cgColor(bodyTop))
+ for hole in eyes + [nose] { ctx.addPath(hole.path(in: stage)) }
+ ctx.fillPath(using: .winding)
+
+ guard let image = ctx.makeImage() else { die("CGContext.makeImage failed at \(pixels)") }
+ return image
+}
+
+func writePNG(_ image: CGImage, to url: URL) {
+ guard
+ let sink = CGImageDestinationCreateWithURL(
+ url as CFURL, UTType.png.identifier as CFString, 1, nil)
+ else { die("cannot open \(url.path) for writing") }
+ CGImageDestinationAddImage(sink, image, nil)
+ guard CGImageDestinationFinalize(sink) else { die("encoding \(url.lastPathComponent) failed") }
+}
+
+// The ten names iconutil demands, spelled out rather than derived: the set is
+// fixed by the tool, and a loop that generated them would be a loop to read
+// before believing the list. 32, 256 and 512 appear twice under two names and
+// are simply drawn twice — a third of a megapixel, against the clarity of not
+// caching anything in a build tool.
+let variants: [(name: String, pixels: Int)] = [
+ ("icon_16x16.png", 16),
+ ("icon_32x32.png", 32),
+ ("icon_128x128.png", 128),
+ ("[email protected]", 256),
+ ("icon_256x256.png", 256),
+ ("[email protected]", 512),
+ ("icon_512x512.png", 512),
+ ("[email protected]", 1024),
+]
+
+let arguments = CommandLine.arguments
+guard arguments.count == 2 else {
+ die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) <output-directory>")
+}
+
+let files = FileManager.default
+let outputDir = URL(fileURLWithPath: arguments[1], isDirectory: true)
+let output = outputDir.appendingPathComponent("pardes.icns")
+
+// A fixed scratch path, cleared before use rather than a unique one: a run that
+// died half way leaves a partial iconset behind, and iconutil would happily
+// fold the stale sizes into the next icns without a word.
+let scratch = files.temporaryDirectory.appendingPathComponent("pardes-icon", isDirectory: true)
+let iconset = scratch.appendingPathComponent("pardes.iconset", isDirectory: true)
+
+try? files.removeItem(at: scratch)
+do {
+ try files.createDirectory(at: iconset, withIntermediateDirectories: true)
+ try files.createDirectory(at: outputDir, withIntermediateDirectories: true)
+} catch {
+ die("cannot create \(iconset.path): \(error.localizedDescription)")
+}
+
+for variant in variants {
+ writePNG(renderIcon(pixels: variant.pixels), to: iconset.appendingPathComponent(variant.name))
+}
+
+let iconutil = Process()
+iconutil.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
+iconutil.arguments = ["-c", "icns", "-o", output.path, iconset.path]
+do {
+ try iconutil.run()
+} catch {
+ die("cannot run /usr/bin/iconutil: \(error.localizedDescription)")
+}
+iconutil.waitUntilExit()
+guard iconutil.terminationStatus == 0 else {
+ die("iconutil exited \(iconutil.terminationStatus) over \(iconset.path)")
+}
+
+try? files.removeItem(at: scratch)