diff options
Diffstat (limited to 'src/macos/icon.swift')
| -rw-r--r-- | src/macos/icon.swift | 183 |
1 files changed, 112 insertions, 71 deletions
diff --git a/src/macos/icon.swift b/src/macos/icon.swift index b5de1838..2e775660 100644 --- a/src/macos/icon.swift +++ b/src/macos/icon.swift @@ -1,15 +1,17 @@ // 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 +// 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. // -// 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. +// 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 @@ -23,7 +25,8 @@ 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. +/// 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) @@ -60,30 +63,80 @@ let cursor = RGB(0xFC_E9_4F) // ansi16[11], bright yellow 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. +// 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 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 -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 + +/// 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, @@ -100,18 +153,6 @@ func cgColor(_ rgb: RGB, alpha: CGFloat = 1) -> CGColor { 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( @@ -157,40 +198,40 @@ func renderIcon(pixels: Int) -> CGImage { // 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.minX, y: body.minY, - width: side, height: max(1, (side * tagHeight).rounded()))) + 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() - // 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))) + // 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, alpha: liveLineAlpha)) - ctx.fill( - snap( - CGRect( - x: leftEdge, y: body.minY + side * liveLineTop, - width: side * liveLineWidth, height: side * lineHeight))) + ctx.setFillColor(cgColor(text)) + for blob in silhouette { ctx.addPath(blob.path(in: stage)) } + ctx.fillPath(using: .winding) - // 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))) + // 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 |
