diff --git a/Sources/CodexBar/IconRenderer.swift b/Sources/CodexBar/IconRenderer.swift index 15e103da1d..77803c2827 100644 --- a/Sources/CodexBar/IconRenderer.swift +++ b/Sources/CodexBar/IconRenderer.swift @@ -35,6 +35,7 @@ enum IconRenderer { let stale: Bool let style: Int let indicator: Int + let tintHash: Int } private final class IconCacheStore: @unchecked Sendable { @@ -118,13 +119,16 @@ enum IconRenderer { blink: CGFloat = 0, wiggle: CGFloat = 0, tilt: CGFloat = 0, - statusIndicator: ProviderStatusIndicator = .none) -> NSImage + statusIndicator: ProviderStatusIndicator = .none, + tintColor: NSColor? = nil) -> NSImage { let shouldCache = blink <= 0.0001 && wiggle <= 0.0001 && tilt <= 0.0001 let render = { - self.renderImage { - // Keep monochrome template icons; Claude uses subtle shape cues only. - let baseFill = NSColor.labelColor + self.renderImage(tintColor: tintColor) { + // When a tintColor is provided (macOS 26+ Liquid Glass), draw shapes directly in that + // color so the bitmap has real RGB values. Otherwise use labelColor for template rendering + // tinted via the status button's contentTintColor. + let baseFill = tintColor ?? NSColor.labelColor let trackFillAlpha: CGFloat = stale ? 0.18 : 0.28 let trackStrokeAlpha: CGFloat = stale ? 0.28 : 0.44 let fillColor = baseFill.withAlphaComponent(stale ? 0.55 : 1.0) @@ -738,7 +742,7 @@ enum IconRenderer { drawBar(rectPx: creditsBottomRectPx, remaining: bottomValue) } - Self.drawStatusOverlay(indicator: statusIndicator) + Self.drawStatusOverlay(indicator: statusIndicator, tintColor: tintColor) } } @@ -749,7 +753,8 @@ enum IconRenderer { credits: self.quantizedCredits(creditsRemaining), stale: stale, style: self.styleKey(style), - indicator: self.indicatorKey(statusIndicator)) + indicator: self.indicatorKey(statusIndicator), + tintHash: self.tintColorHash(tintColor)) if let cached = self.cachedIcon(for: key) { return cached } @@ -800,6 +805,21 @@ enum IconRenderer { self.styleKeyLookup[style] ?? 0 } + private static func tintColorHash(_ color: NSColor?) -> Int { + guard let color else { return 0 } + // Quantize to 256 buckets per channel to avoid cache explosion while preserving visual fidelity. + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + (color.usingColorSpace(.sRGB) ?? color).getRed(&r, green: &g, blue: &b, alpha: &a) + let ri = Int((r * 255).rounded()) + let gi = Int((g * 255).rounded()) + let bi = Int((b * 255).rounded()) + let ai = Int((a * 255).rounded()) + return ri << 24 | gi << 16 | bi << 8 | ai + } + private static func indicatorKey(_ indicator: ProviderStatusIndicator) -> Int { switch indicator { case .none: 0 @@ -932,9 +952,9 @@ enum IconRenderer { path.fill() } - private static func drawStatusOverlay(indicator: ProviderStatusIndicator) { + private static func drawStatusOverlay(indicator: ProviderStatusIndicator, tintColor: NSColor? = nil) { guard indicator.hasIssue else { return } - let color = NSColor.labelColor + let color = tintColor ?? NSColor.labelColor switch indicator { case .minor, .maintenance: @@ -988,7 +1008,7 @@ enum IconRenderer { CGRect(x: self.snap(x), y: self.snap(y), width: self.snap(width), height: self.snap(height)) } - private static func renderImage(_ draw: () -> Void) -> NSImage { + private static func renderImage(tintColor: NSColor? = nil, _ draw: () -> Void) -> NSImage { let image = NSImage(size: Self.outputSize) if let rep = NSBitmapImageRep( @@ -999,7 +1019,7 @@ enum IconRenderer { samplesPerPixel: 4, hasAlpha: true, isPlanar: false, - colorSpaceName: .deviceRGB, + colorSpaceName: .calibratedRGB, bytesPerRow: 0, bitsPerPixel: 0) { @@ -1019,7 +1039,9 @@ enum IconRenderer { image.unlockFocus() } - image.isTemplate = true + // A colored icon must be non-template so macOS 26 Liquid Glass keeps its RGB pixels + // instead of re-rendering it as a monochrome template. + image.isTemplate = tintColor == nil return image } } diff --git a/Sources/CodexBar/MenuBarDisplayMode.swift b/Sources/CodexBar/MenuBarDisplayMode.swift index 484d209693..658b18fa4b 100644 --- a/Sources/CodexBar/MenuBarDisplayMode.swift +++ b/Sources/CodexBar/MenuBarDisplayMode.swift @@ -26,3 +26,20 @@ enum MenuBarDisplayMode: String, CaseIterable, Identifiable { } } } + +/// Controls which time window drives the percent and pace values in the menu bar. +enum MenuBarTimeWindow: String, CaseIterable, Identifiable { + case session + case weekly + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .session: "Session" + case .weekly: "Weekly" + } + } +} diff --git a/Sources/CodexBar/MenuBarDisplayText.swift b/Sources/CodexBar/MenuBarDisplayText.swift index 10c4987eac..8e8c8f7729 100644 --- a/Sources/CodexBar/MenuBarDisplayText.swift +++ b/Sources/CodexBar/MenuBarDisplayText.swift @@ -20,7 +20,8 @@ enum MenuBarDisplayText { mode: MenuBarDisplayMode, percentWindow: RateWindow?, pace: UsagePace? = nil, - showUsed: Bool) -> String? + showUsed: Bool, + separatorStyle: MenuBarSeparatorStyle = .dot) -> String? { switch mode { case .percent: @@ -31,7 +32,7 @@ enum MenuBarDisplayText { guard let percent = percentText(window: percentWindow, showUsed: showUsed) else { return nil } // Fall back to percent-only when pace is unavailable (e.g. Copilot) guard let paceText = Self.paceText(pace: pace) else { return percent } - return "\(percent) · \(paceText)" + return "\(percent)\(separatorStyle.separator)\(paceText)" } } } diff --git a/Sources/CodexBar/MenuBarSeparatorStyle.swift b/Sources/CodexBar/MenuBarSeparatorStyle.swift new file mode 100644 index 0000000000..456c3a6aab --- /dev/null +++ b/Sources/CodexBar/MenuBarSeparatorStyle.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Controls the separator character between percent and pace in the menu bar. +enum MenuBarSeparatorStyle: String, CaseIterable, Identifiable { + case dot + case pipe + + var id: String { + self.rawValue + } + + var separator: String { + switch self { + case .dot: " · " + case .pipe: " | " + } + } + + var label: String { + switch self { + case .dot: "Dot (·)" + case .pipe: "Pipe (|)" + } + } +} diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 63de3853b1..7466986b35 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -37,6 +37,10 @@ struct DisplayPane: View { binding: self.$settings.menuBarShowsHighestUsage) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) + PreferenceToggleRow( + title: "Color-coded icons", + subtitle: "Tint menu bar icons green, yellow, or red based on session usage.", + binding: self.$settings.colorCodedIcons) PreferenceToggleRow( title: L("menu_bar_shows_percent_title"), subtitle: L("menu_bar_shows_percent_subtitle"), @@ -61,6 +65,73 @@ struct DisplayPane: View { } .disabled(!self.settings.menuBarShowsBrandIconWithPercent) .opacity(self.settings.menuBarShowsBrandIconWithPercent ? 1 : 0.5) + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Separator") + .font(.body) + Text("Character between percent and pace (e.g. 45% | +5%).") + .font(.footnote) + .foregroundStyle(.tertiary) + } + Spacer() + Picker("Separator", selection: self.$settings.menuBarSeparatorStyle) { + ForEach(MenuBarSeparatorStyle.allCases) { style in + Text(style.label).tag(style) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } + .disabled(!self.settings.menuBarShowsBrandIconWithPercent || + self.settings.menuBarDisplayMode != .both) + .opacity(self.settings.menuBarShowsBrandIconWithPercent && + self.settings.menuBarDisplayMode == .both ? 1 : 0.5) + VStack(alignment: .leading, spacing: 4) { + Text("Time windows") + .font(.body) + Text("Choose which time window drives the percent and pace values.") + .font(.footnote) + .foregroundStyle(.tertiary) + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 6) { + GridRow { + Text("Percent:") + .font(.callout) + Picker( + "Percent time window", + selection: self.$settings.menuBarPercentTimeWindow) + { + ForEach(MenuBarTimeWindow.allCases) { window in + Text(window.label).tag(window) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: 160) + } + .disabled(self.settings.menuBarDisplayMode == .pace) + .opacity(self.settings.menuBarDisplayMode == .pace ? 0.5 : 1) + GridRow { + Text("Pace:") + .font(.callout) + Picker( + "Pace time window", + selection: self.$settings.menuBarPaceTimeWindow) + { + ForEach(MenuBarTimeWindow.allCases) { window in + Text(window.label).tag(window) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: 160) + } + .disabled(self.settings.menuBarDisplayMode == .percent) + .opacity(self.settings.menuBarDisplayMode == .percent ? 0.5 : 1) + } + } + .disabled(!self.settings.menuBarShowsBrandIconWithPercent) + .opacity(self.settings.menuBarShowsBrandIconWithPercent ? 1 : 0.5) } Divider() diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 2b353ddb1e..27f4ae0916 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -242,6 +242,57 @@ extension SettingsStore { set { self.menuBarDisplayModeRaw = newValue.rawValue } } + private var menuBarSeparatorStyleRaw: String? { + get { self.defaultsState.menuBarSeparatorStyleRaw } + set { + self.defaultsState.menuBarSeparatorStyleRaw = newValue + if let raw = newValue { + self.userDefaults.set(raw, forKey: "menuBarSeparatorStyle") + } else { + self.userDefaults.removeObject(forKey: "menuBarSeparatorStyle") + } + } + } + + var menuBarSeparatorStyle: MenuBarSeparatorStyle { + get { MenuBarSeparatorStyle(rawValue: self.menuBarSeparatorStyleRaw ?? "") ?? .dot } + set { self.menuBarSeparatorStyleRaw = newValue.rawValue } + } + + private var menuBarPercentTimeWindowRaw: String? { + get { self.defaultsState.menuBarPercentTimeWindowRaw } + set { + self.defaultsState.menuBarPercentTimeWindowRaw = newValue + if let raw = newValue { + self.userDefaults.set(raw, forKey: "menuBarPercentTimeWindow") + } else { + self.userDefaults.removeObject(forKey: "menuBarPercentTimeWindow") + } + } + } + + var menuBarPercentTimeWindow: MenuBarTimeWindow { + get { MenuBarTimeWindow(rawValue: self.menuBarPercentTimeWindowRaw ?? "") ?? .session } + set { self.menuBarPercentTimeWindowRaw = newValue.rawValue } + } + + private var menuBarPaceTimeWindowRaw: String? { + get { self.defaultsState.menuBarPaceTimeWindowRaw } + set { + self.defaultsState.menuBarPaceTimeWindowRaw = newValue + if let raw = newValue { + self.userDefaults.set(raw, forKey: "menuBarPaceTimeWindow") + } else { + self.userDefaults.removeObject(forKey: "menuBarPaceTimeWindow") + } + } + } + + var menuBarPaceTimeWindow: MenuBarTimeWindow { + get { MenuBarTimeWindow(rawValue: self.menuBarPaceTimeWindowRaw ?? "") ?? .weekly } + set { self.menuBarPaceTimeWindowRaw = newValue.rawValue } + } + private var kiroMenuBarDisplayModeRaw: String? { get { self.defaultsState.kiroMenuBarDisplayModeRaw } set { @@ -435,6 +486,14 @@ extension SettingsStore { } } + var colorCodedIcons: Bool { + get { self.defaultsState.colorCodedIcons } + set { + self.defaultsState.colorCodedIcons = newValue + self.userDefaults.set(newValue, forKey: "colorCodedIcons") + } + } + var mergeIcons: Bool { get { self.defaultsState.mergeIcons } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 3776c1bbae..600ad3c0dc 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -26,6 +26,9 @@ extension SettingsStore { _ = self.menuBarShowsBrandIconWithPercent _ = self.menuBarShowsHighestUsage _ = self.menuBarDisplayMode + _ = self.menuBarSeparatorStyle + _ = self.menuBarPercentTimeWindow + _ = self.menuBarPaceTimeWindow _ = self.kiroMenuBarDisplayMode _ = self.historicalTrackingEnabled _ = self.multiAccountMenuLayout @@ -61,6 +64,7 @@ extension SettingsStore { _ = self.ampCookieSource _ = self.t3ChatCookieSource _ = self.ollamaCookieSource + _ = self.colorCodedIcons _ = self.mergeIcons _ = self.switcherShowsIcons _ = self.mergedMenuLastSelectedWasOverview diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 379441f99a..4446acb7b7 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -281,6 +281,7 @@ extension SettingsStore { return hadExistingConfig } + // swiftlint:disable:next function_body_length private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState { let refreshDefault = userDefaults.string(forKey: "refreshFrequency") .flatMap(RefreshFrequency.init(rawValue:)) @@ -332,6 +333,12 @@ extension SettingsStore { forKey: "menuBarShowsBrandIconWithPercent") as? Bool ?? false let menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode") ?? MenuBarDisplayMode.percent.rawValue + let menuBarSeparatorStyleRaw = userDefaults.string(forKey: "menuBarSeparatorStyle") + ?? MenuBarSeparatorStyle.dot.rawValue + let menuBarPercentTimeWindowRaw = userDefaults.string(forKey: "menuBarPercentTimeWindow") + ?? MenuBarTimeWindow.session.rawValue + let menuBarPaceTimeWindowRaw = userDefaults.string(forKey: "menuBarPaceTimeWindow") + ?? MenuBarTimeWindow.weekly.rawValue let kiroMenuBarDisplayModeRaw = userDefaults.string(forKey: "kiroMenuBarDisplayMode") ?? KiroMenuBarDisplayMode.automatic.rawValue let historicalTrackingEnabled = userDefaults.object(forKey: "historicalTrackingEnabled") as? Bool ?? false @@ -372,6 +379,7 @@ extension SettingsStore { userDefaults.set(false, forKey: "providerStorageFootprintsEnabled") } let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" + let colorCodedIcons = userDefaults.object(forKey: "colorCodedIcons") as? Bool ?? true let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true let mergedMenuLastSelectedWasOverview = userDefaults.object( @@ -407,6 +415,9 @@ extension SettingsStore { providerChangelogLinksEnabled: providerChangelogLinksEnabled, menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent, menuBarDisplayModeRaw: menuBarDisplayModeRaw, + menuBarSeparatorStyleRaw: menuBarSeparatorStyleRaw, + menuBarPercentTimeWindowRaw: menuBarPercentTimeWindowRaw, + menuBarPaceTimeWindowRaw: menuBarPaceTimeWindowRaw, kiroMenuBarDisplayModeRaw: kiroMenuBarDisplayModeRaw, historicalTrackingEnabled: historicalTrackingEnabled, multiAccountMenuLayoutRaw: multiAccountMenuLayoutRaw, @@ -425,6 +436,7 @@ extension SettingsStore { openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled, providerStorageFootprintsEnabled: providerStorageFootprintsEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, + colorCodedIcons: colorCodedIcons, mergeIcons: mergeIcons, switcherShowsIcons: switcherShowsIcons, mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index cd46f186d5..0e5c79f4a5 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -25,6 +25,9 @@ struct SettingsDefaultsState { var providerChangelogLinksEnabled: Bool var menuBarShowsBrandIconWithPercent: Bool var menuBarDisplayModeRaw: String? + var menuBarSeparatorStyleRaw: String? + var menuBarPercentTimeWindowRaw: String? + var menuBarPaceTimeWindowRaw: String? var kiroMenuBarDisplayModeRaw: String? var historicalTrackingEnabled: Bool var multiAccountMenuLayoutRaw: String @@ -43,6 +46,7 @@ struct SettingsDefaultsState { var openAIWebBatterySaverEnabled: Bool var providerStorageFootprintsEnabled: Bool var jetbrainsIDEBasePath: String + var colorCodedIcons: Bool var mergeIcons: Bool var switcherShowsIcons: Bool var mergedMenuLastSelectedWasOverview: Bool diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 510e3f0792..ae50b8c4b4 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -320,6 +320,15 @@ extension StatusItemController { } return .none }() + + let usageColor: NSColor? = { + guard self.settings.colorCodedIcons, !needsAnimation else { return nil } + return UsageColorLevel.tintColor(for: snapshot?.primary?.usedPercent) + }() + let tintSignature = usageColor == nil + ? "nil" + : Self.iconSignatureValue(snapshot?.primary?.usedPercent) + if showBrandPercent, let brand = ProviderBrandIcon.image(for: primaryProvider) { @@ -334,6 +343,7 @@ extension StatusItemController { "stale=\(stale ? "1" : "0")", "status=\(statusIndicator.rawValue)", "text=\(displayText ?? "nil")", + "tint=\(tintSignature)", "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") @@ -344,6 +354,7 @@ extension StatusItemController { self.setButtonImage( warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) self.setButtonTitle(displayText, for: button) + self.setButtonTintColor(usageColor, for: button) self.noteIconPerfRender(skipped: false) return false } @@ -366,6 +377,7 @@ extension StatusItemController { let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) self.setButtonImage( warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) + self.setButtonTintColor(nil, for: button) } else { let signature = [ "mode=icon", @@ -379,6 +391,7 @@ extension StatusItemController { "blink=\(Self.iconSignatureValue(Double(blink)))", "wiggle=\(Self.iconSignatureValue(Double(wiggle)))", "tilt=\(Self.iconSignatureValue(Double(tilt)))", + "tint=\(tintSignature)", "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") @@ -395,9 +408,11 @@ extension StatusItemController { blink: blink, wiggle: wiggle, tilt: tilt, - statusIndicator: statusIndicator) + statusIndicator: statusIndicator, + tintColor: usageColor) self.setButtonImage( warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) + self.setButtonTintColor(usageColor, for: button) } self.noteIconPerfRender(skipped: false) return false @@ -424,7 +439,7 @@ extension StatusItemController { } @discardableResult - func applyIcon(for provider: UsageProvider, phase: Double?) -> Bool { + func applyIcon(for provider: UsageProvider, phase: Double?) -> Bool { // swiftlint:disable:this function_body_length guard let button = self.statusItems[provider]?.button else { return false } let snapshot = self.store.snapshot(for: provider) // IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the @@ -434,6 +449,15 @@ extension StatusItemController { let style: IconStyle = self.store.style(for: provider) let warningFlash = self.quotaWarningFlashActive(provider: provider) + let isAnimatingForColor = phase != nil && self.shouldAnimate(provider: provider) + let usageColor: NSColor? = { + guard self.settings.colorCodedIcons, !isAnimatingForColor else { return nil } + return UsageColorLevel.tintColor(for: snapshot?.primary?.usedPercent) + }() + let tintSignature = usageColor == nil + ? "nil" + : Self.iconSignatureValue(snapshot?.primary?.usedPercent) + if showBrandPercent, let brand = ProviderBrandIcon.image(for: provider) { @@ -443,6 +467,7 @@ extension StatusItemController { "provider=\(provider.rawValue)", "style=\(String(describing: style))", "text=\(displayText ?? "nil")", + "tint=\(tintSignature)", "warningFlash=\(warningFlash ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipProviderIconRender(provider: provider, signature: signature) { @@ -452,6 +477,7 @@ extension StatusItemController { self.setButtonImage( warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) self.setButtonTitle(displayText, for: button) + self.setButtonTintColor(usageColor, for: button) self.noteIconPerfRender(skipped: false) return false } @@ -548,6 +574,7 @@ extension StatusItemController { let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) self.setButtonImage( warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) + self.setButtonTintColor(nil, for: button) } else { let signature = [ "mode=icon", @@ -561,6 +588,7 @@ extension StatusItemController { "blink=\(Self.iconSignatureValue(Double(blink)))", "wiggle=\(Self.iconSignatureValue(Double(wiggle)))", "tilt=\(Self.iconSignatureValue(Double(tilt)))", + "tint=\(tintSignature)", "warningFlash=\(warningFlash ? "1" : "0")", "loading=\(isLoading ? "1" : "0")", ].joined(separator: "|") @@ -577,9 +605,11 @@ extension StatusItemController { blink: blink, wiggle: wiggle, tilt: tilt, - statusIndicator: statusIndicator) + statusIndicator: statusIndicator, + tintColor: usageColor) self.setButtonImage( warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) + self.setButtonTintColor(usageColor, for: button) } self.noteIconPerfRender(skipped: false) return false @@ -618,6 +648,11 @@ extension StatusItemController { button.image = image } + private func setButtonTintColor(_ color: NSColor?, for button: NSStatusBarButton) { + if button.contentTintColor == color { return } + button.contentTintColor = color + } + private func setButtonTitle(_ title: String?, for button: NSStatusBarButton) { let value = Self.buttonTitle(title, hasImage: button.image != nil) if button.title != value { @@ -673,7 +708,12 @@ extension StatusItemController { return spend } - let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot) + let percentWindow: RateWindow? = switch self.settings.menuBarPercentTimeWindow { + case .session: + self.menuBarPercentWindow(for: provider, snapshot: snapshot) + case .weekly: + snapshot?.secondary ?? self.menuBarPercentWindow(for: provider, snapshot: snapshot) + } let mode = self.settings.menuBarDisplayMode let now = Date() let codexProjection = self.store.codexConsumerProjectionIfNeeded( @@ -686,20 +726,29 @@ extension StatusItemController { case .percent: pace = nil case .pace, .both: - let weeklyWindow = - codexProjection?.rateWindow(for: .weekly) - ?? snapshot?.secondary - // Abacus has no secondary window; pace is computed on primary monthly credits - ?? (provider == .abacus ? snapshot?.primary : nil) - pace = weeklyWindow.flatMap { window in - self.store.weeklyPace(provider: provider, window: window, now: now) + switch self.settings.menuBarPaceTimeWindow { + case .session: + let sessionWindow = snapshot?.primary ?? snapshot?.secondary + pace = sessionWindow.flatMap { window in + UsagePaceText.sessionPace(provider: provider, window: window, now: now) + } + case .weekly: + let weeklyWindow = + codexProjection?.rateWindow(for: .weekly) + ?? snapshot?.secondary + // Abacus has no secondary window; pace is computed on primary monthly credits + ?? (provider == .abacus ? snapshot?.primary : nil) + pace = weeklyWindow.flatMap { window in + self.store.weeklyPace(provider: provider, window: window, now: now) + } } } let displayText = MenuBarDisplayText.displayText( mode: mode, percentWindow: percentWindow, pace: pace, - showUsed: self.settings.usageBarsShowUsed) + showUsed: self.settings.usageBarsShowUsed, + separatorStyle: self.settings.menuBarSeparatorStyle) if mode == .percent, !self.settings.usageBarsShowUsed, diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index a7fe4c684e..ee6d2048f0 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -1283,13 +1283,17 @@ final class CodexAccountSwitcherView: NSView { var emailWidth = max(minimumEmailWidth, contentWidth * 0.58) var workspaceWidth = max(minimumWorkspaceWidth, contentWidth - emailWidth) - func makeTitle() -> String { - let email = self.truncateMiddle(account.email, toFit: emailWidth) - let workspace = self.truncateTail(workspace, toFit: workspaceWidth) - return "\(email)\(separator)\(workspace)" + /// Note: takes the widths as parameters rather than capturing the mutable + /// `emailWidth` / `workspaceWidth` vars below. Capturing those `var`s in a + /// nested function crashes swift-frontend (IRGen, SIGABRT) under the + /// Swift 6.2.3 + macOS 26.4 SDK toolchain. + func makeTitle(emailWidth: CGFloat, workspaceWidth: CGFloat) -> String { + let emailText = self.truncateMiddle(account.email, toFit: emailWidth) + let workspaceText = self.truncateTail(workspace, toFit: workspaceWidth) + return "\(emailText)\(separator)\(workspaceText)" } - var title = makeTitle() + var title = makeTitle(emailWidth: emailWidth, workspaceWidth: workspaceWidth) var attempts = 0 while self.textWidth(title) > availableTextWidth, attempts < 16 { let emailText = self.truncateMiddle(account.email, toFit: emailWidth) @@ -1305,7 +1309,7 @@ final class CodexAccountSwitcherView: NSView { break } - title = makeTitle() + title = makeTitle(emailWidth: emailWidth, workspaceWidth: workspaceWidth) attempts += 1 } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 97ccca628a..4eacbb4ca0 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -190,6 +190,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastKnownScreenCount: Int var pendingScreenChangePreviousCount: Int? var screenChangeVisibilityTask: Task? + private var appearanceObservation: NSKeyValueObservation? let loginLogger = CodexBarLog.logger(LogCategories.login) let menuLogger = CodexBarLog.logger(LogCategories.app) var selectedMenuProvider: UsageProvider? { @@ -358,6 +359,21 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin selector: #selector(self.handleScreenParametersDidChange(_:)), name: NSApplication.didChangeScreenParametersNotification, object: nil) + + // On macOS 26+, usage colors are baked into non-template images. Re-render when the system + // appearance changes so dynamic colors (systemGreen/Orange/Red) resolve to their new values. + // The render-skip signatures don't encode appearance, so clear them first — otherwise an + // unchanged usage/status value would short-circuit the re-render and leave the stale bitmap. + if #available(macOS 26, *) { + self.appearanceObservation = NSApp.observe(\.effectiveAppearance) { [weak self] _, _ in + Task { @MainActor in + guard let self else { return } + self.lastAppliedMergedIconRenderSignature = nil + self.lastAppliedProviderIconRenderSignatures.removeAll() + self.updateIcons() + } + } + } } convenience init( diff --git a/Sources/CodexBar/UsageColorLevel.swift b/Sources/CodexBar/UsageColorLevel.swift new file mode 100644 index 0000000000..bb2eb6bc52 --- /dev/null +++ b/Sources/CodexBar/UsageColorLevel.swift @@ -0,0 +1,23 @@ +import AppKit + +enum UsageColorLevel: Sendable { + /// Returns a smoothly interpolated tint color based on usage percentage. + /// - 0-70%: green blending toward orange + /// - 70-90%: orange blending toward red + /// - >= 90%: red + /// - nil usage: returns nil (monochrome fallback) + static func tintColor(for usedPercent: Double?) -> NSColor? { + guard let pct = usedPercent else { return nil } + let clamped = min(max(pct, 0), 100) + + if clamped < 70 { + let fraction = CGFloat(clamped / 70) + return NSColor.systemGreen.blended(withFraction: fraction, of: .systemOrange) + } else if clamped < 90 { + let fraction = CGFloat((clamped - 70) / 20) + return NSColor.systemOrange.blended(withFraction: fraction, of: .systemRed) + } else { + return .systemRed + } + } +} diff --git a/Tests/CodexBarTests/MenuBarSeparatorStyleTests.swift b/Tests/CodexBarTests/MenuBarSeparatorStyleTests.swift new file mode 100644 index 0000000000..e1414c47d8 --- /dev/null +++ b/Tests/CodexBarTests/MenuBarSeparatorStyleTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import CodexBar + +struct MenuBarSeparatorStyleTests { + @Test + func separatorCharacters() { + #expect(MenuBarSeparatorStyle.dot.separator == " · ") + #expect(MenuBarSeparatorStyle.pipe.separator == " | ") + } + + @Test + func idMatchesRawValue() { + for style in MenuBarSeparatorStyle.allCases { + #expect(style.id == style.rawValue) + } + } + + @Test + func allCasesCoverDotAndPipe() { + #expect(MenuBarSeparatorStyle.allCases == [.dot, .pipe]) + } + + @Test + func rawValueRoundTripFallsBackToDot() { + #expect(MenuBarSeparatorStyle(rawValue: "dot") == .dot) + #expect(MenuBarSeparatorStyle(rawValue: "pipe") == .pipe) + #expect(MenuBarSeparatorStyle(rawValue: "garbage") == nil) + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index ba9bc457dc..cca5e433c4 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -1103,4 +1103,28 @@ struct StatusItemAnimationTests { #expect(baselineAlpha < 0.01) #expect(outputAlpha > 0.01) } + + @Test + func `menu bar time window settings round trip`() { + // These settings persist to UserDefaults.standard; clear leftovers so the defaults check is meaningful. + UserDefaults.standard.removeObject(forKey: "menuBarPercentTimeWindow") + UserDefaults.standard.removeObject(forKey: "menuBarPaceTimeWindow") + + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-timewindow"), + zaiTokenStore: NoopZaiTokenStore()) + + // Backward-compatible defaults: percent tracks the session, pace tracks the week. + #expect(settings.menuBarPercentTimeWindow == .session) + #expect(settings.menuBarPaceTimeWindow == .weekly) + + settings.menuBarPercentTimeWindow = .weekly + settings.menuBarPaceTimeWindow = .session + + #expect(settings.menuBarPercentTimeWindow == .weekly) + #expect(settings.menuBarPaceTimeWindow == .session) + + #expect(settings.userDefaults.string(forKey: "menuBarPercentTimeWindow") == "weekly") + #expect(settings.userDefaults.string(forKey: "menuBarPaceTimeWindow") == "session") + } } diff --git a/Tests/CodexBarTests/UsageColorLevelTests.swift b/Tests/CodexBarTests/UsageColorLevelTests.swift new file mode 100644 index 0000000000..125e282496 --- /dev/null +++ b/Tests/CodexBarTests/UsageColorLevelTests.swift @@ -0,0 +1,45 @@ +import AppKit +import Testing +@testable import CodexBar + +struct UsageColorLevelTests { + private func redComponent(_ color: NSColor?) -> CGFloat? { + guard let resolved = color?.usingColorSpace(.sRGB) else { return nil } + var r: CGFloat = 0 + resolved.getRed(&r, green: nil, blue: nil, alpha: nil) + return r + } + + @Test + func nilUsageReturnsNoTint() { + #expect(UsageColorLevel.tintColor(for: nil) == nil) + } + + @Test + func highUsageIsSystemRed() { + #expect(UsageColorLevel.tintColor(for: 90) == .systemRed) + #expect(UsageColorLevel.tintColor(for: 100) == .systemRed) + // Values above 100 are clamped and still red. + #expect(UsageColorLevel.tintColor(for: 250) == .systemRed) + } + + @Test + func rednessIncreasesWithUsage() throws { + let low = try #require(self.redComponent(UsageColorLevel.tintColor(for: 10))) + let mid = try #require(self.redComponent(UsageColorLevel.tintColor(for: 80))) + let high = try #require(self.redComponent(UsageColorLevel.tintColor(for: 95))) + #expect(low < mid) + #expect(mid <= high) + } + + @Test + func lowUsageIsGreenDominant() throws { + let color = try #require(UsageColorLevel.tintColor(for: 0)?.usingColorSpace(.sRGB)) + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + color.getRed(&r, green: &g, blue: &b, alpha: nil) + #expect(g > r) + #expect(g > b) + } +}