// 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 and a nose 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_16x16@2x.png", 32), ("icon_32x32.png", 32), ("icon_32x32@2x.png", 64), ("icon_128x128.png", 128), ("icon_128x128@2x.png", 256), ("icon_256x256.png", 256), ("icon_256x256@2x.png", 512), ("icon_512x512.png", 512), ("icon_512x512@2x.png", 1024), ] let arguments = CommandLine.arguments guard arguments.count == 2 else { die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) ") } 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)