diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-10 09:58:31 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 09:58:59 -0300 |
| commit | eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (patch) | |
| tree | cc993ad239451adb6f23645ee5ca001802832ed0 /src/macos/icon.swift | |
| parent | 38e9919a9ea9055538409b388d580c4e4c838434 (diff) | |
| download | pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.tar.gz pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.zip | |
macos: the AppKit shell, its icon, and the offscreen e2e harness
Diffstat (limited to 'src/macos/icon.swift')
| -rw-r--r-- | src/macos/icon.swift | 266 |
1 files changed, 266 insertions, 0 deletions
diff --git a/src/macos/icon.swift b/src/macos/icon.swift new file mode 100644 index 00000000..b5de1838 --- /dev/null +++ b/src/macos/icon.swift @@ -0,0 +1,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) |
