Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 deletions Sources/CodexBar/IconRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ enum IconRenderer {
let stale: Bool
let style: Int
let indicator: Int
let tintHash: Int
}

private final class IconCacheStore: @unchecked Sendable {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -738,7 +742,7 @@ enum IconRenderer {
drawBar(rectPx: creditsBottomRectPx, remaining: bottomValue)
}

Self.drawStatusOverlay(indicator: statusIndicator)
Self.drawStatusOverlay(indicator: statusIndicator, tintColor: tintColor)
}
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -999,7 +1019,7 @@ enum IconRenderer {
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
colorSpaceName: .calibratedRGB,
bytesPerRow: 0,
bitsPerPixel: 0)
{
Expand All @@ -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
}
}
Expand Down
17 changes: 17 additions & 0 deletions Sources/CodexBar/MenuBarDisplayMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
5 changes: 3 additions & 2 deletions Sources/CodexBar/MenuBarDisplayText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)"
}
}
}
25 changes: 25 additions & 0 deletions Sources/CodexBar/MenuBarSeparatorStyle.swift
Original file line number Diff line number Diff line change
@@ -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 (|)"
}
}
}
71 changes: 71 additions & 0 deletions Sources/CodexBar/PreferencesDisplayPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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) {
Comment thread
johnlarkin1 marked this conversation as resolved.
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()
Expand Down
59 changes: 59 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/SettingsStore+MenuObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ extension SettingsStore {
_ = self.menuBarShowsBrandIconWithPercent
_ = self.menuBarShowsHighestUsage
_ = self.menuBarDisplayMode
_ = self.menuBarSeparatorStyle
_ = self.menuBarPercentTimeWindow
_ = self.menuBarPaceTimeWindow
_ = self.kiroMenuBarDisplayMode
_ = self.historicalTrackingEnabled
_ = self.multiAccountMenuLayout
Expand Down Expand Up @@ -61,6 +64,7 @@ extension SettingsStore {
_ = self.ampCookieSource
_ = self.t3ChatCookieSource
_ = self.ollamaCookieSource
_ = self.colorCodedIcons
_ = self.mergeIcons
_ = self.switcherShowsIcons
_ = self.mergedMenuLastSelectedWasOverview
Expand Down
12 changes: 12 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -407,6 +415,9 @@ extension SettingsStore {
providerChangelogLinksEnabled: providerChangelogLinksEnabled,
menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent,
menuBarDisplayModeRaw: menuBarDisplayModeRaw,
menuBarSeparatorStyleRaw: menuBarSeparatorStyleRaw,
menuBarPercentTimeWindowRaw: menuBarPercentTimeWindowRaw,
menuBarPaceTimeWindowRaw: menuBarPaceTimeWindowRaw,
kiroMenuBarDisplayModeRaw: kiroMenuBarDisplayModeRaw,
historicalTrackingEnabled: historicalTrackingEnabled,
multiAccountMenuLayoutRaw: multiAccountMenuLayoutRaw,
Expand All @@ -425,6 +436,7 @@ extension SettingsStore {
openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled,
providerStorageFootprintsEnabled: providerStorageFootprintsEnabled,
jetbrainsIDEBasePath: jetbrainsIDEBasePath,
colorCodedIcons: colorCodedIcons,
mergeIcons: mergeIcons,
switcherShowsIcons: switcherShowsIcons,
mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview,
Expand Down
Loading