summaryrefslogtreecommitdiff
path: root/src/macos/icon.swift
blob: b5de1838e78865d1bb76c25ea7c93dff6764b7ff (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
// Draws pardes.app's icon at build time and hands the result to iconutil.
//
// It is generated instead of committed because the mark IS the palette: the
// body is defaultBG, the tag bar and the block cursor are entries out of the
// same ansi16 table src/macos/Sources/PardesView.swift paints cells with. 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.
//
// src/macos/build-app.sh compiles this file alone into a temporary binary and
// runs it with the bundle's Contents/Resources 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 the script has `set -e` waiting for the exit code.
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

// The composition, as fractions of the body square. It has to survive being
// twelve pixels across, so it is four bands and nothing else: the full-width
// tag bar, a dim line for the pane that does not hold the keyboard, a gutter
// wide enough to read as the boundary between panes, then the focused pane's
// line with the block cursor parked at its end. Three shapes under the bar is
// the entire budget; a fourth is grey mush at 16 pixels.
let tagHeight: CGFloat = 0.165
let sidePad: CGFloat = 0.145
let lineHeight: CGFloat = 0.085
let dimLineTop: CGFloat = 0.355
let dimLineWidth: CGFloat = 0.505
let dimLineAlpha: CGFloat = 0.52
let liveLineTop: CGFloat = 0.645
let liveLineWidth: CGFloat = 0.300
let liveLineAlpha: CGFloat = 0.72
let cursorLeft: CGFloat = 0.515
// A cell, not a square: a block cursor covers a whole character box, so it is
// wider than the gap before it and well over twice the height of the ink it
// sits on. The exact numbers are also the ones that survive rounding — 0.205
// of a twelve-pixel body spans 2.46 pixels, which lands on two rows wherever
// the top edge falls, whereas anything near 0.155 collapses to one row for
// half the possible alignments and the block turns into a dash.
let cursorWidth: CGFloat = 0.130
let cursorHeight: CGFloat = 0.205

/// 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
}

/// Whole device pixels, or the 16pt render turns each one-pixel bar into two
/// rows of half-lit grey and the whole mark goes soft. Rounding the edges
/// rather than the size is what keeps the gaps between bands even.
func snap(_ rect: CGRect) -> CGRect {
    let left = rect.minX.rounded()
    let top = rect.minY.rounded()
    return CGRect(
        x: left, y: top,
        width: max(1, rect.maxX.rounded() - left),
        height: max(1, rect.maxY.rounded() - top))
}

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))
    ctx.fill(
        CGRect(
            x: body.minX, y: body.minY,
            width: side, height: max(1, (side * tagHeight).rounded())))
    ctx.restoreGState()

    // Two panes. The gap between the lines is deliberately the widest space in
    // the icon: it is the pane boundary, and proximity is the only way to say
    // so without spending the fourth shape on a divider.
    let leftEdge = body.minX + side * sidePad
    ctx.setFillColor(cgColor(text, alpha: dimLineAlpha))
    ctx.fill(
        snap(
            CGRect(
                x: leftEdge, y: body.minY + side * dimLineTop,
                width: side * dimLineWidth, height: side * lineHeight)))

    ctx.setFillColor(cgColor(text, alpha: liveLineAlpha))
    ctx.fill(
        snap(
            CGRect(
                x: leftEdge, y: body.minY + side * liveLineTop,
                width: side * liveLineWidth, height: side * lineHeight)))

    // Centred on the line it follows and taller than it, the way a block cursor
    // covers a whole cell rather than the ink in it. Full-strength yellow
    // against the blue is what tells you at a glance which pane has focus.
    let cursorTop = liveLineTop + (lineHeight - cursorHeight) / 2
    ctx.setFillColor(cgColor(cursor))
    ctx.fill(
        snap(
            CGRect(
                x: body.minX + side * cursorLeft, y: body.minY + side * cursorTop,
                width: side * cursorWidth, height: side * cursorHeight)))

    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)