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.swift248
1 files changed, 67 insertions, 181 deletions
diff --git a/src/macos/icon.swift b/src/macos/icon.swift
index 5c7dd881..2f45220f 100644
--- a/src/macos/icon.swift
+++ b/src/macos/icon.swift
@@ -1,22 +1,29 @@
-// Draws pardes.app's icon at build time and hands the result to iconutil.
+// Cuts pardes.app's icon out of a committed drawing 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.
+// The mark is still 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. What changed is
+// where she comes from: she used to be drawn here out of the terminal's own
+// palette, four ellipses and a tag bar, and she is now `icon.png` beside this
+// file. A drawing is not derivable from a palette, so the old argument for
+// generating her ("a checked-in .icns stops matching the app the first time a
+// colour moves") no longer applies to the artwork — but it still applies to
+// the ICNS, which is why this file did not become a committed binary. What is
+// committed is the source picture, in one format, reviewable as an image; what
+// is generated is the ten-size container macOS actually reads.
+//
+// So the work here is no longer drawing. It is the part a designer's PNG never
+// has: Apple's icon grid, the rounded-square mask, and ten exact sizes.
//
// 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.
+// source PNG as argv[1] and the bundle's Resources directory as argv[2];
+// 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.
//
// 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.
+// 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
@@ -32,29 +39,6 @@ func die(_ message: String) -> Never {
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
@@ -63,98 +47,35 @@ let cursor = RGB(0xFC_E9_4F) // ansi16[11], bright yellow
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)
- }
-}
+/// Where the crop comes off when the source is not square.
+///
+/// The drawing is 1204x1306 — taller than wide — so filling a square throws
+/// away 8% of its height, and WHICH 8% is the whole decision. Anchoring the
+/// top keeps the sun, which is the only warm thing in the picture and sits in
+/// the top-right corner, and spends the loss on the bottom band of grass,
+/// which is texture and repeats. Anchoring the centre would clip the sun's
+/// rays to buy back grass, which is the trade backwards. 0 is top, 1 is
+/// bottom; a square source ignores this entirely.
+let cropAnchor: CGFloat = 0
-// 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.
+/// sRGB in the bitmap, so the paper white in the drawing is the paper white in
+/// the icon rather than whatever DeviceRGB would make of it.
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")
+func loadSource(_ url: URL) -> CGImage {
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
+ die("cannot read \(url.path)")
+ }
+ guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
+ die("\(url.lastPathComponent) holds no decodable image")
}
- return color
+ return image
}
-func renderIcon(pixels: Int) -> CGImage {
+func renderIcon(_ art: CGImage, pixels: Int) -> CGImage {
guard
let ctx = CGContext(
data: nil, width: pixels, height: pixels,
@@ -162,11 +83,6 @@ func renderIcon(pixels: Int) -> CGImage {
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.
@@ -176,63 +92,32 @@ func renderIcon(pixels: Int) -> CGImage {
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)
+ // Aspect FILL, not fit. Fitting would letterbox the rounded square with a
+ // flat band beside textured paper, and the seam between the two is visible
+ // at every size the band is wide enough to see. Filling overflows the clip
+ // instead, and the clip is already exact.
+ let artWidth = CGFloat(art.width)
+ let artHeight = CGFloat(art.height)
+ guard artWidth > 0, artHeight > 0 else { die("source image is empty") }
+ let scale = max(side / artWidth, side / artHeight)
+ let drawWidth = artWidth * scale
+ let drawHeight = artHeight * scale
- // 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)
+ // CoreGraphics is bottom-left origin, so `cropAnchor` 0 (the TOP of the
+ // picture) means the drawing's top edge meets the body's top edge and the
+ // overflow hangs off the bottom, into the clip.
+ let overflowY = drawHeight - side
+ let overflowX = drawWidth - side
+ ctx.interpolationQuality = .high
+ ctx.draw(
+ art,
+ in: CGRect(
+ x: body.minX - overflowX / 2,
+ y: body.maxY - drawHeight + overflowY * cropAnchor,
+ width: drawWidth, height: drawHeight))
guard let image = ctx.makeImage() else { die("CGContext.makeImage failed at \(pixels)") }
return image
@@ -266,12 +151,13 @@ let variants: [(name: String, pixels: Int)] = [
]
let arguments = CommandLine.arguments
-guard arguments.count == 2 else {
- die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) <output-directory>")
+guard arguments.count == 3 else {
+ die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) <source.png> <output-directory>")
}
let files = FileManager.default
-let outputDir = URL(fileURLWithPath: arguments[1], isDirectory: true)
+let art = loadSource(URL(fileURLWithPath: arguments[1]))
+let outputDir = URL(fileURLWithPath: arguments[2], isDirectory: true)
let output = outputDir.appendingPathComponent("pardes.icns")
// A fixed scratch path, cleared before use rather than a unique one: a run that
@@ -289,7 +175,7 @@ do {
}
for variant in variants {
- writePNG(renderIcon(pixels: variant.pixels), to: iconset.appendingPathComponent(variant.name))
+ writePNG(renderIcon(art, pixels: variant.pixels), to: iconset.appendingPathComponent(variant.name))
}
let iconutil = Process()