Skip to content

Commit 61e8dad

Browse files
johnlarkin1claude
andauthored
feat: fix menu bar colors not showing on macOS 26 (Liquid Glass) (#16)
* feat: fix menu bar colors not showing on macOS 26 (Liquid Glass) macOS 26 Liquid Glass overrides contentTintColor on template images in the menu bar, breaking CodexBar's color-coded usage icons. On macOS 26+, bake the tint color directly into image pixels using sourceIn compositing and set isTemplate=false. On older macOS, keep the existing template + contentTintColor approach unchanged. Also modernizes deprecated APIs: - Replace lockFocus/unlockFocus with NSImage(size:flipped:drawingHandler:) - Replace deviceRGB with calibratedRGB for consistent rendering - Replace NSColor(deviceRed:) with NSColor(red:green:blue:alpha:) - Add appearance change observer to re-render on dark/light mode switch Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Bump version to 0.18.0-beta.3-jl.3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4c556e8 commit 61e8dad

7 files changed

Lines changed: 176 additions & 66 deletions

File tree

.claude/settings.local.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
"mcp__plugin_github_github__search_code",
1010
"mcp__plugin_github_github__search_pull_requests",
1111
"Bash(git log:*)",
12-
"Bash(./Scripts/lint.sh lint:*)"
12+
"Bash(./Scripts/lint.sh lint:*)",
13+
"Bash(grep -r macOS /Users/johnlarkin/Documents/coding/macos-cc-usage-project/CodexBar/*.toml /Users/johnlarkin/Documents/coding/macos-cc-usage-project/CodexBar/Package.swift)",
14+
"Bash(grep:*)",
15+
"Bash(xargs cat:*)",
16+
"Skill(generate-pr)"
1317
]
1418
}
1519
}

Sources/CodexBar/IconRenderer.swift

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ enum IconRenderer {
3535
let stale: Bool
3636
let style: Int
3737
let indicator: Int
38+
let tintHash: Int
3839
}
3940

4041
private final class IconCacheStore: @unchecked Sendable {
@@ -118,11 +119,12 @@ enum IconRenderer {
118119
blink: CGFloat = 0,
119120
wiggle: CGFloat = 0,
120121
tilt: CGFloat = 0,
121-
statusIndicator: ProviderStatusIndicator = .none) -> NSImage
122+
statusIndicator: ProviderStatusIndicator = .none,
123+
tintColor: NSColor? = nil) -> NSImage
122124
{
123125
let shouldCache = blink <= 0.0001 && wiggle <= 0.0001 && tilt <= 0.0001
124126
let render = {
125-
self.renderImage {
127+
self.renderImage(tintColor: tintColor) {
126128
// Keep monochrome template icons; Claude uses subtle shape cues only.
127129
let baseFill = NSColor.labelColor
128130
let trackFillAlpha: CGFloat = stale ? 0.18 : 0.28
@@ -749,7 +751,8 @@ enum IconRenderer {
749751
credits: self.quantizedCredits(creditsRemaining),
750752
stale: stale,
751753
style: self.styleKey(style),
752-
indicator: self.indicatorKey(statusIndicator))
754+
indicator: self.indicatorKey(statusIndicator),
755+
tintHash: self.tintColorHash(tintColor))
753756
if let cached = self.cachedIcon(for: key) {
754757
return cached
755758
}
@@ -811,6 +814,21 @@ enum IconRenderer {
811814
}
812815
}
813816

817+
private static func tintColorHash(_ color: NSColor?) -> Int {
818+
guard let color else { return 0 }
819+
// Quantize to 256 buckets per channel to avoid cache explosion while preserving visual fidelity.
820+
var r: CGFloat = 0
821+
var g: CGFloat = 0
822+
var b: CGFloat = 0
823+
var a: CGFloat = 0
824+
(color.usingColorSpace(.sRGB) ?? color).getRed(&r, green: &g, blue: &b, alpha: &a)
825+
let ri = Int((r * 255).rounded())
826+
let gi = Int((g * 255).rounded())
827+
let bi = Int((b * 255).rounded())
828+
let ai = Int((a * 255).rounded())
829+
return ri << 24 | gi << 16 | bi << 8 | ai
830+
}
831+
814832
private static func morphCacheKey(progress: Double, style: IconStyle) -> NSNumber {
815833
let bucket = Int((progress * Double(self.morphBucketCount)).rounded())
816834
let key = self.styleKey(style) * 1000 + bucket
@@ -988,7 +1006,7 @@ enum IconRenderer {
9881006
CGRect(x: self.snap(x), y: self.snap(y), width: self.snap(width), height: self.snap(height))
9891007
}
9901008

991-
private static func renderImage(_ draw: () -> Void) -> NSImage {
1009+
private static func renderImage(tintColor: NSColor? = nil, _ draw: () -> Void) -> NSImage {
9921010
let image = NSImage(size: Self.outputSize)
9931011

9941012
if let rep = NSBitmapImageRep(
@@ -999,7 +1017,7 @@ enum IconRenderer {
9991017
samplesPerPixel: 4,
10001018
hasAlpha: true,
10011019
isPlanar: false,
1002-
colorSpaceName: .deviceRGB,
1020+
colorSpaceName: .calibratedRGB,
10031021
bytesPerRow: 0,
10041022
bitsPerPixel: 0)
10051023
{
@@ -1010,16 +1028,29 @@ enum IconRenderer {
10101028
if let ctx = NSGraphicsContext(bitmapImageRep: rep) {
10111029
NSGraphicsContext.current = ctx
10121030
Self.withScaledContext(draw)
1031+
1032+
// On macOS 26+, bake the tint color into the image pixels using sourceIn compositing
1033+
// so the color survives Liquid Glass's template-image pipeline.
1034+
if let tintColor {
1035+
let cgCtx = ctx.cgContext
1036+
let scaledSize = CGSize(
1037+
width: Self.outputSize.width * Self.outputScale,
1038+
height: Self.outputSize.height * Self.outputScale)
1039+
cgCtx.saveGState()
1040+
cgCtx.setBlendMode(.sourceIn)
1041+
tintColor.setFill()
1042+
cgCtx.fill(CGRect(origin: .zero, size: scaledSize))
1043+
cgCtx.restoreGState()
1044+
}
10131045
}
10141046
NSGraphicsContext.restoreGraphicsState()
1015-
} else {
1016-
// Fallback to legacy focus if the bitmap rep fails for any reason.
1017-
image.lockFocus()
1018-
Self.withScaledContext(draw)
1019-
image.unlockFocus()
10201047
}
10211048

1022-
image.isTemplate = true
1049+
if tintColor != nil {
1050+
image.isTemplate = false
1051+
} else {
1052+
image.isTemplate = true
1053+
}
10231054
return image
10241055
}
10251056
}

Sources/CodexBar/StatusItemController+Animation.swift

Lines changed: 99 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,15 @@ extension StatusItemController {
294294
let brand = ProviderBrandIcon.image(for: primaryProvider)
295295
{
296296
let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot)
297-
self.setButtonImage(brand, for: button)
297+
if #available(macOS 26, *) {
298+
let tinted = Self.tintedBrandImage(brand, color: usageColor)
299+
self.setButtonImage(tinted, for: button)
300+
self.setButtonTintColor(nil, for: button)
301+
} else {
302+
self.setButtonImage(brand, for: button)
303+
self.setButtonTintColor(usageColor, for: button)
304+
}
298305
self.setButtonTitle(displayText, for: button)
299-
self.setButtonTintColor(usageColor, for: button)
300306
return
301307
}
302308

@@ -326,18 +332,34 @@ extension StatusItemController {
326332
self.setButtonImage(image, for: button)
327333
self.setButtonTintColor(nil, for: button)
328334
} else {
329-
let image = IconRenderer.makeIcon(
330-
primaryRemaining: primary,
331-
weeklyRemaining: weekly,
332-
creditsRemaining: credits,
333-
stale: stale,
334-
style: style,
335-
blink: blink,
336-
wiggle: wiggle,
337-
tilt: tilt,
338-
statusIndicator: statusIndicator)
339-
self.setButtonImage(image, for: button)
340-
self.setButtonTintColor(usageColor, for: button)
335+
if #available(macOS 26, *) {
336+
let image = IconRenderer.makeIcon(
337+
primaryRemaining: primary,
338+
weeklyRemaining: weekly,
339+
creditsRemaining: credits,
340+
stale: stale,
341+
style: style,
342+
blink: blink,
343+
wiggle: wiggle,
344+
tilt: tilt,
345+
statusIndicator: statusIndicator,
346+
tintColor: usageColor)
347+
self.setButtonImage(image, for: button)
348+
self.setButtonTintColor(nil, for: button)
349+
} else {
350+
let image = IconRenderer.makeIcon(
351+
primaryRemaining: primary,
352+
weeklyRemaining: weekly,
353+
creditsRemaining: credits,
354+
stale: stale,
355+
style: style,
356+
blink: blink,
357+
wiggle: wiggle,
358+
tilt: tilt,
359+
statusIndicator: statusIndicator)
360+
self.setButtonImage(image, for: button)
361+
self.setButtonTintColor(usageColor, for: button)
362+
}
341363
}
342364
}
343365

@@ -359,9 +381,15 @@ extension StatusItemController {
359381
let brand = ProviderBrandIcon.image(for: provider)
360382
{
361383
let displayText = self.menuBarDisplayText(for: provider, snapshot: snapshot)
362-
self.setButtonImage(brand, for: button)
384+
if #available(macOS 26, *) {
385+
let tinted = Self.tintedBrandImage(brand, color: usageColor)
386+
self.setButtonImage(tinted, for: button)
387+
self.setButtonTintColor(nil, for: button)
388+
} else {
389+
self.setButtonImage(brand, for: button)
390+
self.setButtonTintColor(usageColor, for: button)
391+
}
363392
self.setButtonTitle(displayText, for: button)
364-
self.setButtonTintColor(usageColor, for: button)
365393
return
366394
}
367395

@@ -448,18 +476,34 @@ extension StatusItemController {
448476
self.setButtonTintColor(nil, for: button)
449477
} else {
450478
self.setButtonTitle(nil, for: button)
451-
let image = IconRenderer.makeIcon(
452-
primaryRemaining: primary,
453-
weeklyRemaining: weekly,
454-
creditsRemaining: credits,
455-
stale: stale,
456-
style: style,
457-
blink: blink,
458-
wiggle: wiggle,
459-
tilt: tilt,
460-
statusIndicator: self.store.statusIndicator(for: provider))
461-
self.setButtonImage(image, for: button)
462-
self.setButtonTintColor(usageColor, for: button)
479+
if #available(macOS 26, *) {
480+
let image = IconRenderer.makeIcon(
481+
primaryRemaining: primary,
482+
weeklyRemaining: weekly,
483+
creditsRemaining: credits,
484+
stale: stale,
485+
style: style,
486+
blink: blink,
487+
wiggle: wiggle,
488+
tilt: tilt,
489+
statusIndicator: self.store.statusIndicator(for: provider),
490+
tintColor: usageColor)
491+
self.setButtonImage(image, for: button)
492+
self.setButtonTintColor(nil, for: button)
493+
} else {
494+
let image = IconRenderer.makeIcon(
495+
primaryRemaining: primary,
496+
weeklyRemaining: weekly,
497+
creditsRemaining: credits,
498+
stale: stale,
499+
style: style,
500+
blink: blink,
501+
wiggle: wiggle,
502+
tilt: tilt,
503+
statusIndicator: self.store.statusIndicator(for: provider))
504+
self.setButtonImage(image, for: button)
505+
self.setButtonTintColor(usageColor, for: button)
506+
}
463507
}
464508
}
465509

@@ -670,19 +714,37 @@ extension StatusItemController {
670714
{
671715
guard statusIndicator.hasIssue else { return brand }
672716

673-
let image = NSImage(size: brand.size)
674-
image.lockFocus()
675-
brand.draw(
676-
at: .zero,
677-
from: NSRect(origin: .zero, size: brand.size),
678-
operation: .sourceOver,
679-
fraction: 1.0)
680-
Self.drawBrandStatusOverlay(indicator: statusIndicator, size: brand.size)
681-
image.unlockFocus()
717+
let image = NSImage(size: brand.size, flipped: false) { rect in
718+
brand.draw(
719+
at: .zero,
720+
from: rect,
721+
operation: .sourceOver,
722+
fraction: 1.0)
723+
Self.drawBrandStatusOverlay(indicator: statusIndicator, size: brand.size)
724+
return true
725+
}
682726
image.isTemplate = brand.isTemplate
683727
return image
684728
}
685729

730+
/// Composites a tint color onto a brand image using sourceIn blending, producing a non-template image
731+
/// suitable for macOS 26 Liquid Glass where `contentTintColor` is ignored on template images.
732+
nonisolated static func tintedBrandImage(_ brand: NSImage, color: NSColor?) -> NSImage {
733+
guard let color else { return brand }
734+
let image = NSImage(size: brand.size, flipped: false) { rect in
735+
brand.draw(in: rect, from: rect, operation: .sourceOver, fraction: 1.0)
736+
guard let ctx = NSGraphicsContext.current?.cgContext else { return true }
737+
ctx.saveGState()
738+
ctx.setBlendMode(.sourceIn)
739+
color.setFill()
740+
ctx.fill(rect)
741+
ctx.restoreGState()
742+
return true
743+
}
744+
image.isTemplate = false
745+
return image
746+
}
747+
686748
private nonisolated static func drawBrandStatusOverlay(indicator: ProviderStatusIndicator, size: NSSize) {
687749
guard indicator.hasIssue else { return }
688750

Sources/CodexBar/StatusItemController+SwitcherViews.swift

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -714,15 +714,15 @@ final class ProviderSwitcherView: NSView {
714714

715715
private static func paddedImage(_ image: NSImage, leading: CGFloat) -> NSImage {
716716
let size = NSSize(width: image.size.width + leading, height: image.size.height)
717-
let newImage = NSImage(size: size)
718-
newImage.lockFocus()
719-
let y = (size.height - image.size.height) / 2
720-
image.draw(
721-
at: NSPoint(x: leading, y: y),
722-
from: NSRect(origin: .zero, size: image.size),
723-
operation: .sourceOver,
724-
fraction: 1.0)
725-
newImage.unlockFocus()
717+
let newImage = NSImage(size: size, flipped: false) { _ in
718+
let y = (size.height - image.size.height) / 2
719+
image.draw(
720+
at: NSPoint(x: leading, y: y),
721+
from: NSRect(origin: .zero, size: image.size),
722+
operation: .sourceOver,
723+
fraction: 1.0)
724+
return true
725+
}
726726
newImage.isTemplate = image.isTemplate
727727
return newImage
728728
}
@@ -774,7 +774,11 @@ final class ProviderSwitcherView: NSView {
774774
switch selection {
775775
case let .provider(provider):
776776
let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color
777-
return NSColor(deviceRed: color.red, green: color.green, blue: color.blue, alpha: 1)
777+
return NSColor(
778+
red: CGFloat(color.red),
779+
green: CGFloat(color.green),
780+
blue: CGFloat(color.blue),
781+
alpha: 1.0)
778782
case .overview:
779783
return NSColor.secondaryLabelColor
780784
}

Sources/CodexBar/StatusItemController.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
9292
var lastSwitcherProviders: [UsageProvider] = []
9393
/// Tracks which switcher tab state was used for the current merged-menu switcher instance.
9494
var lastMergedSwitcherSelection: ProviderSwitcherSelection?
95+
private var appearanceObservation: NSKeyValueObservation?
9596
let loginLogger = CodexBarLog.logger(LogCategories.login)
9697
var selectedMenuProvider: UsageProvider? {
9798
get { self.settings.selectedMenuProvider }
@@ -195,6 +196,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
195196
selector: #selector(self.handleProviderConfigDidChange),
196197
name: .codexbarProviderConfigDidChange,
197198
object: nil)
199+
200+
// On macOS 26+, usage colors are baked into non-template images. Re-render when the system
201+
// appearance changes so dynamic colors (systemGreen/Orange/Red) resolve to their new values.
202+
if #available(macOS 26, *) {
203+
self.appearanceObservation = NSApp.observe(\.effectiveAppearance) { [weak self] _, _ in
204+
Task { @MainActor in self?.updateIcons() }
205+
}
206+
}
198207
}
199208

200209
private func wireBindings() {

Tests/CodexBarTests/StatusItemAnimationTests.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -658,11 +658,11 @@ struct StatusItemAnimationTests {
658658
@Test
659659
func brandImageWithStatusOverlayDrawsIssueMark() throws {
660660
let size = NSSize(width: 16, height: 16)
661-
let brand = NSImage(size: size)
662-
brand.lockFocus()
663-
NSColor.clear.setFill()
664-
NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill()
665-
brand.unlockFocus()
661+
let brand = NSImage(size: size, flipped: false) { rect in
662+
NSColor.clear.setFill()
663+
NSBezierPath(rect: rect).fill()
664+
return true
665+
}
666666
brand.isTemplate = true
667667

668668
let baselineData = try #require(brand.tiffRepresentation)

version.env

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
MARKETING_VERSION=0.18.0-beta.3-jl.2
2-
BUILD_NUMBER=52
1+
MARKETING_VERSION=0.18.0-beta.3-jl.3
2+
BUILD_NUMBER=53

0 commit comments

Comments
 (0)