macos-ai-menubar-usage/AIMenubarUsage/Views/ProviderIcon.swift
Felipe M. 2ab9a281f1
Fix menu bar icons for dark backgrounds
Use template rendering so provider icons automatically follow the menu bar appearance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:41:58 +02:00

157 lines
5.1 KiB
Swift

import AppKit
import SwiftUI
enum ProviderIconAsset {
static func name(for provider: ProviderID) -> String {
switch provider {
case .cursor: "ProviderCursor"
case .claude: "ProviderClaude"
}
}
}
enum MenuBarIconRenderer {
/// MenuBarExtra ignores SwiftUI layout and NSImage.size rasterize to exact points.
static func sizedImage(for provider: ProviderID, height: CGFloat = MenuBarMetrics.iconHeight) -> NSImage {
let source = loadProviderImage(for: provider)
guard let source else {
return NSImage(size: NSSize(width: height, height: height))
}
var sourceSize = source.size
if sourceSize.width <= 0 || sourceSize.height <= 0 {
sourceSize = NSSize(width: 24, height: 24)
}
let aspect = sourceSize.width / sourceSize.height
let pointSize = NSSize(width: height * aspect, height: height)
let scale = NSScreen.main?.backingScaleFactor ?? 2
let pixelWidth = max(Int((pointSize.width * scale).rounded(.up)), 1)
let pixelHeight = max(Int((pointSize.height * scale).rounded(.up)), 1)
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: pixelWidth,
pixelsHigh: pixelHeight,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
) else {
return NSImage(size: pointSize)
}
rep.size = pointSize
NSGraphicsContext.saveGraphicsState()
if let context = NSGraphicsContext(bitmapImageRep: rep) {
NSGraphicsContext.current = context
context.imageInterpolation = .high
let dest = NSRect(origin: .zero, size: pointSize)
let src = NSRect(origin: .zero, size: sourceSize)
source.draw(
in: dest,
from: src,
operation: .sourceOver,
fraction: 1.0,
respectFlipped: false,
hints: [.interpolation: NSImageInterpolation.high]
)
}
NSGraphicsContext.restoreGraphicsState()
let image = NSImage(size: pointSize)
image.addRepresentation(rep)
image.isTemplate = true
return image
}
private static func loadProviderImage(for provider: ProviderID) -> NSImage? {
var image: NSImage?
NSApp.effectiveAppearance.performAsCurrentDrawingAppearance {
image = NSImage(named: ProviderIconAsset.name(for: provider))
}
return image
}
}
/// Settings and other in-app UI keep full size.
struct ProviderBrandIcon: View {
let provider: ProviderID
var size: CGFloat = 18
var body: some View {
Image(ProviderIconAsset.name(for: provider))
.resizable()
.interpolation(.high)
.antialiased(true)
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
}
}
enum MenuBarMetrics {
static let iconHeight: CGFloat = 18
static let textSize: CGFloat = 11
static let providerSpacing: CGFloat = 8
static let itemSpacing: CGFloat = 4
static let labelHeight: CGFloat = 20
}
struct ProviderMenuBarIconView: View {
let providerID: ProviderID
let state: ProviderDisplayState?
let settings: AppSettings
@Environment(\.colorScheme) private var colorScheme
private var selectedMetrics: [UsageMetric] {
settings.settings(for: providerID).menuBarMetrics
}
private var badgeText: String? {
guard let snapshot = state?.snapshot else { return nil }
return snapshot.menuBarDisplay(for: selectedMetrics)
}
/// Always show icon + text this app has no Dock icon.
private var statusText: String {
if state?.isLoading == true { return "" }
if let badgeText { return badgeText }
if state?.errorMessage != nil { return "!" }
return ""
}
var body: some View {
HStack(spacing: MenuBarMetrics.itemSpacing) {
Image(nsImage: MenuBarIconRenderer.sizedImage(for: providerID))
.renderingMode(.template)
.foregroundStyle(.primary)
.frame(width: MenuBarMetrics.iconHeight, height: MenuBarMetrics.iconHeight)
.opacity(state?.errorMessage != nil ? 0.45 : 1.0)
Text(statusText)
.font(.system(size: MenuBarMetrics.textSize, weight: .medium))
.foregroundStyle(state?.errorMessage != nil ? .red : .primary)
.lineLimit(1)
}
.fixedSize(horizontal: true, vertical: false)
.frame(height: MenuBarMetrics.labelHeight)
.id(colorScheme)
.help(helpText)
}
private var helpText: String {
if let error = state?.errorMessage {
return "\(providerID.displayName): \(error)"
}
if statusText != "", statusText != "" {
return "\(providerID.displayName): \(statusText)"
}
return providerID.displayName
}
}