summaryrefslogtreecommitdiff
path: root/src/macos/icon.swift
blob: 5c7dd88180e33364b6c1603046dab99f2ba7c34d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
// 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),
    ("[email protected]", 32),
    ("icon_32x32.png", 32),
    ("[email protected]", 64),
    ("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)