Draw the real icon, and give the menu bar its own glyph
Replaces the placeholder coat silhouette with the app's actual subject: Claude's asterisk above a rack of overcoats, on Claude's own coral. The wardrobe is the app in one picture — several coats to pick between, one currently worn. The menu bar stops scaling the app icon down and draws its own glyph instead. At 18pt the icon's three coats, lapels and pockets collapse into a smudge, and its colours ignore the menu bar entirely. The glyph is a template image, so macOS tints it for light and dark and for the open-menu inversion, and re-tints it itself when the theme changes. That also drove its shape: six rays rather than eight, because at that size eight have under a pixel between arms and merge into a blob. The coloured dots beside it don't get that for free — ProfileColor picks a brightness for the current appearance at the moment it's asked, and the title is only rebuilt when profiles start or stop. Observing effectiveAppearance rebuilds it, so a theme switch doesn't leave dots mixed for the old appearance sitting in the menu bar. Everything is still drawn from vector paths rather than exported from a design tool, so each size can be tuned — small sizes need fatter strokes than a straight downscale gives — and nothing binary but the derived assets is committed. The README gains a hero banner, composed from the generated icon so it can't drift out of step with it. About gains the tagline under the icon, names itself in the description rather than opening with a bare verb, and drops the author line.
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
# shannoncoat
|
||||
|
||||
*which coat will Claude wear today?*
|
||||

|
||||
|
||||
Run multiple isolated Claude profiles on macOS — each one its own Claude Desktop
|
||||
**and** its own paired Claude Code setup — and tell their windows apart at a
|
||||
@@ -129,3 +127,9 @@ One thing worth setting up: an unsigned local build gets a new code identity
|
||||
every time, which silently invalidates its Accessibility grant on each rebuild.
|
||||
`.env.example` walks through creating a self-signed certificate once so the
|
||||
permission survives.
|
||||
|
||||
---
|
||||
|
||||
shannoncoat started as the shell script in the first commit, written by hand.
|
||||
Claude Code built the app out of it — most of the Swift, the icon, and this
|
||||
README — working from direction, review and testing.
|
||||
|
||||
+23
-12
@@ -15,12 +15,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
||||
private var accessibilityPromptShown = false
|
||||
private var accessibilityRecheckScheduled = false
|
||||
private var activationObserver: NSObjectProtocol?
|
||||
private var appearanceObserver: NSKeyValueObservation?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
menu.delegate = self
|
||||
menu.autoenablesItems = false
|
||||
statusItem.menu = menu
|
||||
statusItem.button?.image = menuBarIcon()
|
||||
statusItem.button?.image = MenuBarIcon.image()
|
||||
statusItem.button?.imagePosition = .imageLeft
|
||||
|
||||
liveState.delegate = self
|
||||
@@ -34,6 +35,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
||||
activationObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||
forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in self?.updateOverlayVisibility() }
|
||||
|
||||
// The glyph is a template image and re-tints itself, but the coloured
|
||||
// dots beside it don't: ProfileColor picks a brightness per current
|
||||
// appearance at the moment it's asked, and the title is only rebuilt
|
||||
// when profiles start or stop. Without this, switching to dark mode
|
||||
// leaves dots mixed for that appearance sitting in the menu bar until
|
||||
// something unrelated happens to redraw them.
|
||||
appearanceObserver = NSApp.observe(\.effectiveAppearance) { [weak self] _, _ in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.applyTitle(self.currentInfos)
|
||||
ManageWindowController.shared.update(self.currentInfos)
|
||||
}
|
||||
}
|
||||
|
||||
liveState.reconcile()
|
||||
|
||||
// Launching this app fresh with nothing running means there's no
|
||||
@@ -46,17 +62,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
||||
UpdateChecker.checkAutomaticallyIfDue()
|
||||
}
|
||||
|
||||
// Claude's own app icon (bundled as Contents/Resources/AppIcon.icns —
|
||||
// currently a placeholder, see icon/generate-icon.swift), scaled down
|
||||
// for the menu bar.
|
||||
private func menuBarIcon() -> NSImage {
|
||||
let source = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
|
||||
let resized = NSImage(size: NSSize(width: 18, height: 18))
|
||||
resized.lockFocus()
|
||||
source.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18), from: .zero, operation: .sourceOver, fraction: 1.0)
|
||||
resized.unlockFocus()
|
||||
return resized
|
||||
}
|
||||
// The menu bar draws its own glyph (see MenuBarIcon) rather than the app
|
||||
// icon scaled down, which is what it used to do: at 18pt the app icon's
|
||||
// three coats collapse into a smudge, and its colours ignore the menu
|
||||
// bar's appearance entirely. MenuBarIcon returns a template image, so
|
||||
// macOS tints it for light/dark and for the open-menu inversion, and
|
||||
// re-tints it by itself when the theme changes.
|
||||
|
||||
// MARK: - LiveStateDelegate
|
||||
|
||||
|
||||
@@ -640,7 +640,6 @@ private final class SettingsViewController: NSViewController {
|
||||
|
||||
// MARK: - About tab
|
||||
|
||||
private let projectAuthor = "bdeshi"
|
||||
private let projectHomepage = "https://github.com/bdeshi/shannoncoat"
|
||||
|
||||
private final class AboutViewController: NSViewController {
|
||||
@@ -668,19 +667,36 @@ private final class AboutViewController: NSViewController {
|
||||
icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
|
||||
])
|
||||
|
||||
// Centred under the icon, same wrapper trick as the icon itself.
|
||||
let tagline = NSTextField(labelWithString: "which coat will Claude wear today?")
|
||||
tagline.font = .systemFont(ofSize: NSFont.systemFontSize)
|
||||
tagline.textColor = .secondaryLabelColor
|
||||
tagline.translatesAutoresizingMaskIntoConstraints = false
|
||||
let taglineContainer = NSView()
|
||||
taglineContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
taglineContainer.addSubview(tagline)
|
||||
NSLayoutConstraint.activate([
|
||||
tagline.topAnchor.constraint(equalTo: taglineContainer.topAnchor),
|
||||
tagline.bottomAnchor.constraint(equalTo: taglineContainer.bottomAnchor),
|
||||
tagline.centerXAnchor.constraint(equalTo: taglineContainer.centerXAnchor),
|
||||
])
|
||||
|
||||
// Names itself rather than opening with a bare verb — this panel is
|
||||
// reachable without the window title in view.
|
||||
let description = NSTextField(wrappingLabelWithString:
|
||||
"Runs multiple Claude Desktop profiles side by side, each paired with its own isolated Claude Code config.")
|
||||
"shannoncoat runs multiple Claude Desktop profiles side by side, each paired with its own "
|
||||
+ "isolated Claude Code config.")
|
||||
description.font = .systemFont(ofSize: NSFont.systemFontSize)
|
||||
|
||||
let authorField = NSTextField(labelWithString: "Author: \(projectAuthor)")
|
||||
let sourceField = NSTextField(labelWithString: "Source: \(projectHomepage)")
|
||||
let versionField = NSTextField(labelWithString: "Version: \(UpdateChecker.displayVersion)")
|
||||
for field in [authorField, sourceField, versionField] {
|
||||
for field in [sourceField, versionField] {
|
||||
field.textColor = .secondaryLabelColor
|
||||
field.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
}
|
||||
|
||||
let stack = NSStackView(views: [iconContainer, description, authorField, sourceField, versionField])
|
||||
let stack = NSStackView(views: [iconContainer, taglineContainer, description,
|
||||
sourceField, versionField])
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 10
|
||||
@@ -694,6 +710,7 @@ private final class AboutViewController: NSViewController {
|
||||
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
description.widthAnchor.constraint(equalToConstant: Self.contentWidth),
|
||||
iconContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
|
||||
taglineContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
|
||||
])
|
||||
|
||||
view.layoutSubtreeIfNeeded()
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// The menu bar's own glyph — Claude's asterisk over a coat's collar and
|
||||
// shoulders. Drawn here rather than scaled down from the app icon, which is
|
||||
// what the menu bar used to show: at 18pt the app icon's three coats, lapels
|
||||
// and pockets collapse into a smudge, and its colours fight the menu bar
|
||||
// instead of following it.
|
||||
//
|
||||
// Returned as a *template* image, which is what makes it track the system
|
||||
// theme with no code of ours involved. A template carries only an alpha
|
||||
// channel; macOS supplies the colour — dark on a light menu bar, light on a
|
||||
// dark one, inverted again while the menu is open — and re-tints it on the fly
|
||||
// when the theme switches. That also dictates the drawing: no fills of our
|
||||
// own, and any internal detail has to be a hole punched through the alpha.
|
||||
import AppKit
|
||||
|
||||
enum MenuBarIcon {
|
||||
// Six rays rather than the app icon's eight. At 18pt an eight-ray asterisk
|
||||
// has under a pixel of gap between arms, so they merge into a blob; six
|
||||
// resolve as separate arms. Lengths stay slightly uneven, as on the app
|
||||
// icon, so it doesn't read as a snowflake.
|
||||
private static let rayCount = 6
|
||||
private static let rayLengths: [CGFloat] = [1.0, 0.88, 0.97, 0.86, 1.0, 0.9]
|
||||
private static let rayRadius: CGFloat = 22
|
||||
private static let rayWidth: CGFloat = 7.2
|
||||
private static let asteriskCentre = NSPoint(x: 50, y: 28)
|
||||
|
||||
static func image(pointSize: CGFloat = 18) -> NSImage {
|
||||
let image = NSImage(size: NSSize(width: pointSize, height: pointSize))
|
||||
image.lockFocusFlipped(false)
|
||||
|
||||
// Authored y-down like the app icon; flip once rather than mirroring
|
||||
// every coordinate.
|
||||
let flip = NSAffineTransform()
|
||||
flip.translateX(by: 0, yBy: pointSize)
|
||||
flip.scaleX(by: pointSize / 100, yBy: -pointSize / 100)
|
||||
flip.concat()
|
||||
|
||||
NSColor.black.setStroke()
|
||||
asterisk().stroke()
|
||||
NSColor.black.setFill()
|
||||
coat().fill()
|
||||
|
||||
image.unlockFocus()
|
||||
image.isTemplate = true
|
||||
return image
|
||||
}
|
||||
|
||||
private static func asterisk() -> NSBezierPath {
|
||||
let path = NSBezierPath()
|
||||
for index in 0..<rayCount {
|
||||
let angle = (CGFloat(index) * 360 / CGFloat(rayCount) + 8) * .pi / 180
|
||||
let radius = rayRadius * rayLengths[index]
|
||||
path.move(to: asteriskCentre)
|
||||
path.line(to: NSPoint(x: asteriskCentre.x + radius * cos(angle),
|
||||
y: asteriskCentre.y + radius * sin(angle)))
|
||||
}
|
||||
path.lineWidth = rayWidth
|
||||
path.lineCapStyle = .round
|
||||
return path
|
||||
}
|
||||
|
||||
// Read along the top edge from one side to the other: shoulder, a slight
|
||||
// tilt up, a flat collar top, the valley at the neck, then the mirror of
|
||||
// that. Square sides and a flat hem below.
|
||||
//
|
||||
// The flat sections are 13 units wide because anything narrower stops
|
||||
// reading as flat at this size and just looks like part of the slope —
|
||||
// 13 units is roughly 2.3px at 18pt, about the floor for that.
|
||||
private static func coat() -> NSBezierPath {
|
||||
let path = NSBezierPath()
|
||||
path.move(to: NSPoint(x: 15, y: 72)) // left shoulder
|
||||
path.line(to: NSPoint(x: 27, y: 60)) // slight tilt up
|
||||
path.line(to: NSPoint(x: 40, y: 60)) // flat collar top
|
||||
path.line(to: NSPoint(x: 50, y: 78)) // valley at the neck
|
||||
path.line(to: NSPoint(x: 60, y: 60))
|
||||
path.line(to: NSPoint(x: 73, y: 60))
|
||||
path.line(to: NSPoint(x: 85, y: 72)) // right shoulder
|
||||
path.line(to: NSPoint(x: 85, y: 94))
|
||||
path.line(to: NSPoint(x: 15, y: 94))
|
||||
path.close()
|
||||
return path
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
@@ -0,0 +1,83 @@
|
||||
// Generates docs/hero.png — the banner at the top of the README.
|
||||
//
|
||||
// Composes the already-generated app icon with the wordmark and tagline rather
|
||||
// than redrawing the coats, so the banner can never drift out of step with the
|
||||
// icon itself. Run generate-icon.swift first.
|
||||
//
|
||||
// Usage:
|
||||
// swift icon/generate-icon.swift icon/AppIcon.iconset
|
||||
// cp icon/AppIcon.iconset/icon_512x512.png icon/shannoncoat.png
|
||||
// iconutil -c icns icon/AppIcon.iconset -o icon/AppIcon.icns
|
||||
// rm -rf icon/AppIcon.iconset
|
||||
// swift icon/generate-hero.swift icon/shannoncoat.png docs/hero.png
|
||||
import Cocoa
|
||||
|
||||
private func rgb(_ hex: UInt32) -> NSColor {
|
||||
NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255,
|
||||
blue: CGFloat(hex & 255) / 255, alpha: 1)
|
||||
}
|
||||
|
||||
// Slate ground rather than cloud: GitHub renders READMEs on both a light and a
|
||||
// dark page, and a dark banner sits comfortably on either, where a near-white
|
||||
// one glares against a dark page. It also lets the coral icon carry the colour.
|
||||
private let ground = rgb(0x26_26_25)
|
||||
private let wordmark = rgb(0xF0_EE_E6)
|
||||
private let subtitle = rgb(0xA8_A2_98)
|
||||
|
||||
private let width = 1200
|
||||
private let height = 360
|
||||
|
||||
let args = CommandLine.arguments
|
||||
let iconPath = args.count > 1 ? args[1] : "icon/shannoncoat.png"
|
||||
let outPath = args.count > 2 ? args[2] : "docs/hero.png"
|
||||
|
||||
guard let icon = NSImage(contentsOfFile: iconPath) else {
|
||||
fatalError("couldn't read \(iconPath) — run generate-icon.swift first")
|
||||
}
|
||||
|
||||
guard let rep = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
|
||||
) else { fatalError("could not create bitmap rep") }
|
||||
rep.size = NSSize(width: width, height: height)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
|
||||
ground.setFill()
|
||||
NSBezierPath(rect: NSRect(x: 0, y: 0, width: width, height: height)).fill()
|
||||
|
||||
let iconSide: CGFloat = 184
|
||||
icon.draw(in: NSRect(x: 96, y: (CGFloat(height) - iconSide) / 2, width: iconSide, height: iconSide))
|
||||
|
||||
let textX: CGFloat = 96 + iconSide + 56
|
||||
let name = NSAttributedString(string: "shannoncoat", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 76, weight: .semibold),
|
||||
.foregroundColor: wordmark,
|
||||
])
|
||||
let tagline = NSAttributedString(string: "which coat will Claude wear today?", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 30, weight: .regular),
|
||||
.foregroundColor: subtitle,
|
||||
])
|
||||
|
||||
// Stacked around the vertical centre, using each string's own measured height
|
||||
// so the pair sits optically centred against the icon rather than by guesswork.
|
||||
let nameSize = name.size(), taglineSize = tagline.size()
|
||||
let gap: CGFloat = 14
|
||||
let blockHeight = nameSize.height + gap + taglineSize.height
|
||||
let top = (CGFloat(height) + blockHeight) / 2
|
||||
name.draw(at: NSPoint(x: textX, y: top - nameSize.height))
|
||||
tagline.draw(at: NSPoint(x: textX + 3, y: top - nameSize.height - gap - taglineSize.height))
|
||||
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
let outURL = URL(fileURLWithPath: outPath)
|
||||
try? FileManager.default.createDirectory(
|
||||
at: outURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
guard let png = rep.representation(using: .png, properties: [:]) else {
|
||||
fatalError("failed to encode hero PNG")
|
||||
}
|
||||
try! png.write(to: outURL)
|
||||
print("wrote \(outPath)")
|
||||
+174
-75
@@ -1,8 +1,11 @@
|
||||
// Generates a placeholder AppIcon.iconset — a plain rounded-square
|
||||
// background with the coat glyph on top. Deliberately not derived from
|
||||
// Claude's own icon assets (unlike the old version of this file, which
|
||||
// composited on top of Claude.app's icon) — this is a stand-in until a
|
||||
// real icon is designed, so it draws everything itself from vector shapes.
|
||||
// Generates AppIcon.iconset — Claude's asterisk above a rack of overcoats, on
|
||||
// Claude's own coral. The wardrobe is the app in one picture: several coats to
|
||||
// pick between, one currently worn.
|
||||
//
|
||||
// Everything is drawn from vector paths here rather than exported from a
|
||||
// design tool, so a size can be tuned independently (small sizes need fatter
|
||||
// strokes than a straight downscale gives) and nothing binary but the derived
|
||||
// .icns is committed. Deliberately not derived from Claude's own icon assets.
|
||||
//
|
||||
// Usage:
|
||||
// swift icon/generate-icon.swift icon/AppIcon.iconset
|
||||
@@ -10,85 +13,182 @@
|
||||
// rm -rf icon/AppIcon.iconset
|
||||
import Cocoa
|
||||
|
||||
// Claude's coral and cloud, plus tints of each for depth. The lapel is a
|
||||
// lighter grey than the coat and the back coats are darker corals — tone
|
||||
// separation survives downscaling, where outlines don't.
|
||||
private enum Palette {
|
||||
static func rgb(_ hex: UInt32) -> NSColor {
|
||||
NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255,
|
||||
blue: CGFloat(hex & 255) / 255, alpha: 1)
|
||||
}
|
||||
static let tile = rgb(0xD9_77_57)
|
||||
static let coat = rgb(0xF0_EE_E6)
|
||||
static let lapel = rgb(0xB3_AA_97)
|
||||
static let pocket = rgb(0xB8_AF_9C)
|
||||
static let backLeft = rgb(0x8A_3E_2A)
|
||||
static let backLeftPocket = rgb(0x6E_35_24)
|
||||
static let backRight = rgb(0xB4_61_4A)
|
||||
static let backRightPocket = rgb(0x94_4A_34)
|
||||
}
|
||||
|
||||
private func p(_ x: CGFloat, _ y: CGFloat) -> NSPoint { NSPoint(x: x, y: y) }
|
||||
|
||||
// MARK: - Shapes, drawn in a 100x100 space with y pointing down
|
||||
|
||||
// Eight rays of slightly uneven length, a few degrees off the compass points —
|
||||
// a mechanically regular asterisk reads as a snowflake. Wider than tall, so it
|
||||
// doesn't look like it's standing on end.
|
||||
private func asterisk() -> NSBezierPath {
|
||||
let centre = p(50, 24)
|
||||
let tips = [p(65, 24), p(59, 33.3), p(49.5, 38.5), p(40.8, 32.6),
|
||||
p(35, 23.7), p(40.8, 14.5), p(50.2, 9.8), p(58.6, 15.1)]
|
||||
let path = NSBezierPath()
|
||||
for tip in tips { path.move(to: centre); path.line(to: tip) }
|
||||
path.lineWidth = 5.6
|
||||
path.lineCapStyle = .round
|
||||
return path
|
||||
}
|
||||
|
||||
// One coat in its own 40x44 box, so the three can be placed at different
|
||||
// scales and angles. The centre split is a true hole, so the tile shows through.
|
||||
private func coatBody() -> NSBezierPath {
|
||||
let b = NSBezierPath()
|
||||
b.move(to: p(16, 2))
|
||||
b.curve(to: p(3, 6.5), controlPoint1: p(12, 2.5), controlPoint2: p(6, 4))
|
||||
b.curve(to: p(2.5, 42), controlPoint1: p(1.5, 16), controlPoint2: p(1.5, 30))
|
||||
b.line(to: p(37.5, 42))
|
||||
b.curve(to: p(37, 6.5), controlPoint1: p(38.5, 30), controlPoint2: p(38.5, 16))
|
||||
b.curve(to: p(24, 2), controlPoint1: p(34, 4), controlPoint2: p(28, 2.5))
|
||||
b.line(to: p(20, 26))
|
||||
b.close()
|
||||
b.appendRect(NSRect(x: 19.15, y: 26, width: 1.7, height: 16))
|
||||
b.windingRule = .evenOdd
|
||||
return b
|
||||
}
|
||||
|
||||
// One connected band round the neck rather than two tabs on the shoulders,
|
||||
// dipping at centre where the lapel opening begins. Its top corners sit at
|
||||
// x15/x25 so the lapels cover them — any further out and they poke through as
|
||||
// spikes either side of the neck.
|
||||
private func coatCollar() -> NSBezierPath {
|
||||
let c = NSBezierPath()
|
||||
c.move(to: p(15, -1.6)); c.line(to: p(25, -1.6))
|
||||
c.line(to: p(29, 2.6)); c.line(to: p(21.6, 2.6))
|
||||
c.line(to: p(20, 1)); c.line(to: p(18.4, 2.6))
|
||||
c.line(to: p(11, 2.6))
|
||||
c.close()
|
||||
return c
|
||||
}
|
||||
|
||||
// Notch lapels, each one shape running from the collar's outer corner down to
|
||||
// the button point. The notch sits at (12.5, 8) — pulled down and outward
|
||||
// deliberately, because nearer the inner edge it pinches the polygon to a
|
||||
// hairline and the lapel renders as two separate triangles.
|
||||
private func coatLapels() -> NSBezierPath {
|
||||
let l = NSBezierPath()
|
||||
l.move(to: p(15, -1.6)); l.line(to: p(10.5, 3.5)); l.line(to: p(12.5, 8))
|
||||
l.line(to: p(9, 14)); l.line(to: p(20, 26)); l.close()
|
||||
l.move(to: p(25, -1.6)); l.line(to: p(29.5, 3.5)); l.line(to: p(27.5, 8))
|
||||
l.line(to: p(31, 14)); l.line(to: p(20, 26)); l.close()
|
||||
return l
|
||||
}
|
||||
|
||||
// Filled marks, not holes — a hole would show whichever coat is behind it.
|
||||
private func coatPockets() -> NSBezierPath {
|
||||
let path = NSBezierPath()
|
||||
path.appendRect(NSRect(x: 6.5, y: 22, width: 9, height: 1.7))
|
||||
path.appendRect(NSRect(x: 24.5, y: 22, width: 9, height: 1.7))
|
||||
return path
|
||||
}
|
||||
|
||||
// A hairline along the lapel edges separating the grey lapel from the tile
|
||||
// showing through the opening. Deliberately fine: a detail for 128px and up.
|
||||
// Thickening it to survive smaller sizes only blurs the lapel edge, because at
|
||||
// this diagonal a one-pixel stroke covers a fraction of each pixel it crosses.
|
||||
private func coatOpeningBorder() -> NSBezierPath {
|
||||
let path = NSBezierPath()
|
||||
path.move(to: p(15, -1.6)); path.line(to: p(20, 26)); path.line(to: p(25, -1.6))
|
||||
path.lineWidth = 0.7
|
||||
path.lineJoinStyle = .round
|
||||
path.lineCapStyle = .round
|
||||
return path
|
||||
}
|
||||
|
||||
private func placed(_ path: NSBezierPath, x: CGFloat, y: CGFloat,
|
||||
rotation: CGFloat, scale: CGFloat) -> NSBezierPath {
|
||||
let transform = NSAffineTransform()
|
||||
transform.translateX(by: x, yBy: y)
|
||||
if rotation != 0 { transform.rotate(byDegrees: rotation) }
|
||||
transform.scale(by: scale)
|
||||
let copy = path.copy() as! NSBezierPath
|
||||
copy.transform(using: transform as AffineTransform)
|
||||
return copy
|
||||
}
|
||||
|
||||
// Each coat is finished before the next is laid over it. Drawing all three
|
||||
// bodies and then all three sets of details would put the back coats' collars
|
||||
// and pockets on top of the front coat.
|
||||
private func drawCoat(x: CGFloat, y: CGFloat, rotation: CGFloat, scale: CGFloat,
|
||||
body: NSColor, pocket: NSColor, lapel: NSColor? = nil) {
|
||||
body.setFill()
|
||||
placed(coatBody(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
placed(coatCollar(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
if let lapel {
|
||||
lapel.setFill()
|
||||
placed(coatLapels(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
body.setStroke()
|
||||
let border = placed(coatOpeningBorder(), x: x, y: y, rotation: rotation, scale: scale)
|
||||
border.lineWidth = 0.7 * scale
|
||||
border.stroke()
|
||||
}
|
||||
pocket.setFill()
|
||||
placed(coatPockets(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
}
|
||||
|
||||
private func drawIcon() {
|
||||
Palette.tile.setFill()
|
||||
NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: 100, height: 100),
|
||||
xRadius: 22, yRadius: 22).fill()
|
||||
|
||||
Palette.coat.setStroke()
|
||||
asterisk().stroke()
|
||||
|
||||
drawCoat(x: 11, y: 50, rotation: -8, scale: 0.78,
|
||||
body: Palette.backLeft, pocket: Palette.backLeftPocket)
|
||||
drawCoat(x: 60, y: 47, rotation: 8, scale: 0.78,
|
||||
body: Palette.backRight, pocket: Palette.backRightPocket)
|
||||
drawCoat(x: 31.5, y: 44, rotation: 0, scale: 0.92,
|
||||
body: Palette.coat, pocket: Palette.pocket, lapel: Palette.lapel)
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
// Renders at exact pixel dimensions via an explicit NSBitmapImageRep rather
|
||||
// than NSImage.lockFocus() — lockFocus rasterizes at the *main screen's*
|
||||
// backing scale factor, which would silently double every size on a Retina
|
||||
// display instead of producing the exact px asked for.
|
||||
func renderIcon(pixels: Int) -> Data {
|
||||
let s = CGFloat(pixels)
|
||||
private func renderIcon(pixels: Int) -> Data {
|
||||
let side = CGFloat(pixels)
|
||||
guard let rep = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: pixels,
|
||||
pixelsHigh: pixels,
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0
|
||||
bitmapDataPlanes: nil, pixelsWide: pixels, pixelsHigh: pixels,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
|
||||
) else { fatalError("could not create bitmap rep") }
|
||||
rep.size = NSSize(width: s, height: s)
|
||||
rep.size = NSSize(width: side, height: side)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
let ctx = NSGraphicsContext(bitmapImageRep: rep)
|
||||
NSGraphicsContext.current = ctx
|
||||
ctx?.imageInterpolation = .high
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
|
||||
// Base: a plain rounded-square background (macOS "squircle"-ish corner
|
||||
// radius), a neutral slate color — no borrowed assets.
|
||||
let bg = NSColor(calibratedRed: 0.30, green: 0.32, blue: 0.36, alpha: 1.0)
|
||||
let bgPath = NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: s, height: s),
|
||||
xRadius: s * 0.22, yRadius: s * 0.22)
|
||||
bg.setFill()
|
||||
bgPath.fill()
|
||||
// The shapes are authored y-down, the way the drawing reads; flip once
|
||||
// here rather than mirroring every coordinate.
|
||||
let flip = NSAffineTransform()
|
||||
flip.translateX(by: 0, yBy: side)
|
||||
flip.scaleX(by: side / 100, yBy: -side / 100)
|
||||
flip.concat()
|
||||
|
||||
// Foreground: the same coat glyph as before — an original shape, not
|
||||
// derived from any borrowed icon.
|
||||
let coat = NSColor(calibratedRed: 0.62, green: 0.44, blue: 0.24, alpha: 1.0)
|
||||
let coatShadow = NSColor(calibratedRed: 0.48, green: 0.33, blue: 0.16, alpha: 1.0)
|
||||
let midX = s * 0.5
|
||||
let topY = s * 0.62
|
||||
let collarOutX = s * 0.34
|
||||
let collarInX = s * 0.11
|
||||
let shoulderY = s * 0.53
|
||||
let waistY = s * 0.30
|
||||
let waistHalf = s * 0.30
|
||||
let hemY = s * 0.06
|
||||
let hemHalf = s * 0.40
|
||||
drawIcon()
|
||||
|
||||
let path = NSBezierPath()
|
||||
path.move(to: NSPoint(x: midX, y: topY))
|
||||
path.line(to: NSPoint(x: midX - collarOutX, y: shoulderY))
|
||||
path.line(to: NSPoint(x: midX - collarInX, y: shoulderY - s * 0.07))
|
||||
path.line(to: NSPoint(x: midX - waistHalf, y: waistY))
|
||||
path.line(to: NSPoint(x: midX - hemHalf, y: hemY))
|
||||
path.line(to: NSPoint(x: midX + hemHalf, y: hemY))
|
||||
path.line(to: NSPoint(x: midX + waistHalf, y: waistY))
|
||||
path.line(to: NSPoint(x: midX + collarInX, y: shoulderY - s * 0.07))
|
||||
path.line(to: NSPoint(x: midX + collarOutX, y: shoulderY))
|
||||
path.close()
|
||||
coat.setFill()
|
||||
path.fill()
|
||||
|
||||
// A thin darker strip down the centerline reads as the coat's front
|
||||
// seam/placket, breaking up the flat fill at larger sizes.
|
||||
let seam = NSBezierPath()
|
||||
seam.move(to: NSPoint(x: midX, y: topY))
|
||||
seam.line(to: NSPoint(x: midX, y: hemY))
|
||||
seam.lineWidth = max(s * 0.012, 1)
|
||||
coatShadow.setStroke()
|
||||
seam.stroke()
|
||||
|
||||
let buttonRadius = max(s * 0.02, 0.75)
|
||||
for t: CGFloat in [0.20, 0.42, 0.64] {
|
||||
let y = hemY + (shoulderY - hemY) * t
|
||||
let r = NSRect(x: midX - buttonRadius, y: y - buttonRadius, width: buttonRadius * 2, height: buttonRadius * 2)
|
||||
coatShadow.setFill()
|
||||
NSBezierPath(ovalIn: r).fill()
|
||||
}
|
||||
|
||||
ctx?.flushGraphics()
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let png = rep.representation(using: .png, properties: [:]) else {
|
||||
@@ -108,8 +208,7 @@ let sizes: [(name: String, px: Int)] = [
|
||||
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset"
|
||||
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
|
||||
for (name, px) in sizes {
|
||||
let data = renderIcon(pixels: px)
|
||||
let path = "\(outDir)/\(name).png"
|
||||
try? data.write(to: URL(fileURLWithPath: path))
|
||||
try? renderIcon(pixels: px).write(to: URL(fileURLWithPath: path))
|
||||
print("wrote \(path)")
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Reference in New Issue
Block a user