diff --git a/README.md b/README.md index d7b7d90..914faa0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -# shannoncoat - -*which coat will Claude wear today?* +![shannoncoat — which coat will Claude wear today?](docs/hero.png) 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. diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 1fd3545..dee617e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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 diff --git a/Sources/ManageWindow.swift b/Sources/ManageWindow.swift index 9ec5da0..cc7268c 100644 --- a/Sources/ManageWindow.swift +++ b/Sources/ManageWindow.swift @@ -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() diff --git a/Sources/MenuBarIcon.swift b/Sources/MenuBarIcon.swift new file mode 100644 index 0000000..320061e --- /dev/null +++ b/Sources/MenuBarIcon.swift @@ -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.. 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 + } +} diff --git a/docs/hero.png b/docs/hero.png new file mode 100644 index 0000000..eaf7603 Binary files /dev/null and b/docs/hero.png differ diff --git a/icon/AppIcon.icns b/icon/AppIcon.icns index 39e3506..782c2d8 100644 Binary files a/icon/AppIcon.icns and b/icon/AppIcon.icns differ diff --git a/icon/generate-hero.swift b/icon/generate-hero.swift new file mode 100644 index 0000000..fad83ab --- /dev/null +++ b/icon/generate-hero.swift @@ -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)") diff --git a/icon/generate-icon.swift b/icon/generate-icon.swift index f8de3b2..e00480c 100644 --- a/icon/generate-icon.swift +++ b/icon/generate-icon.swift @@ -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)") } diff --git a/icon/shannoncoat.png b/icon/shannoncoat.png new file mode 100644 index 0000000..89e0ced Binary files /dev/null and b/icon/shannoncoat.png differ