summaryrefslogtreecommitdiff
path: root/src/macos/icon.swift
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-10 17:23:18 -0300
committerGabriel Schneider <[email protected]>2026-08-11 09:58:59 -0300
commita797a1ab2f648e773f7a1b28d12bf9441b9f13f4 (patch)
tree45db37a3d190425b3ca2453d46c57d2af27cfc93 /src/macos/icon.swift
parenteb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (diff)
downloadpardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.tar.gz
pardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.zip
macos: pixel attachments, live theming, and a signed app
The AppKit shell now draws what the core renders, follows the theme without a relaunch, and builds into something you can hand to someone. - Pixel attachments. Surface.images was dropped on the floor here, so a PDF pane showed nothing at all: native_images is now set, pardes_image_s carries the geometry the core already clipped, and PardesView keeps one CGImage per (serial, page, revision) so scrolling costs a draw and not a decode. Image panes get real pixels instead of the petscii fallback. - Themes take hold live. pardes_tick never advanced the chrome animation, so every tagline kept the previous theme's colours until the next launch and the 16 ms re-pump spun for the rest of the session. pardes_theme_bg retires the hand-agreed #121212 and drives the window background and the titlebar appearance; a theme with no background of its own now gets a transparent window over an NSVisualEffectView. - The cell snaps to whole DEVICE pixels rather than whole points. Monaco advances 8.4014pt at 14, so ceiling to 9 spaced every column 7.1% wider than the face was drawn for. - The dial is one notch per 10 degrees instead of 20, and a release keeps turning in proportion to how hard it was thrown -- ramping up from zero at the floor, so a slow twist coasts not a little but not at all. - A file dropped on the grid is a click plus Look, so it opens beside the pane it was dropped on. No drop concept was added to the core. - The titlebar follows the focused pane: proxy icon, filename, and the dirty dot. File.saved_revision is the watermark that last one needed. - Config (SPC f c) prints the resolved startup config path. - build.zig assembles, signs and packages the bundle itself; build-app.sh is gone. -Dmacos-identity= takes a Developer ID, macos-dmg makes the image, and the icon is Glenda.
Diffstat (limited to 'src/macos/icon.swift')
-rw-r--r--src/macos/icon.swift183
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