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:
+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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user