From 10140169addd4be10be508673c4ae3fb9b56bf95 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 10 May 2026 21:10:40 +0200 Subject: [PATCH] feat(menu-bar): add monochrome icon mode with template rendering Adds a "Use Colored Status Icon" toggle in Settings (default off). When off, the menu bar icon is rendered as a black-on-clear template image so macOS tints it to match the system menu bar (white in dark mode, black in light mode), aligning with the system tray's monochrome aesthetic. When on, the original green/orange/red status colours are preserved. Colour resolution is centralised in a new MenuBarIconColors helper and threaded through every IconStyle plus the renderer and the icon cache key. AppSettings stays backwards compatible: an existing serialised settings blob without "use_colored_icon" decodes with the new field defaulted to off. --- ClaudeMeter/Models/AppSettings.swift | 43 +++++++++++- ClaudeMeter/Views/MenuBar/IconCache.swift | 17 +++-- .../MenuBar/IconStyles/BatteryIcon.swift | 33 ++------- .../IconStyles/CircularGaugeIcon.swift | 13 ++-- .../MenuBar/IconStyles/DualBarIcon.swift | 37 +++------- .../Views/MenuBar/IconStyles/GaugeIcon.swift | 11 ++- .../MenuBar/IconStyles/MinimalIcon.swift | 9 +-- .../MenuBar/IconStyles/SegmentedBarIcon.swift | 14 ++-- .../Views/MenuBar/MenuBarIconRenderer.swift | 10 ++- .../Views/MenuBar/MenuBarIconView.swift | 68 +++++++++++++++++-- .../Views/MenuBar/MenuBarManager.swift | 11 ++- ClaudeMeter/Views/Settings/SettingsView.swift | 23 +++++++ 12 files changed, 188 insertions(+), 101 deletions(-) diff --git a/ClaudeMeter/Models/AppSettings.swift b/ClaudeMeter/Models/AppSettings.swift index 2719c9c..186ed73 100644 --- a/ClaudeMeter/Models/AppSettings.swift +++ b/ClaudeMeter/Models/AppSettings.swift @@ -30,6 +30,11 @@ struct AppSettings: Codable, Equatable, Sendable { /// Menu bar icon display style var iconStyle: IconStyle + /// Tint the menu bar icon with the green/orange/red status colours. When false (default), + /// the icon is rendered as a template image so macOS tints it to match the system menu bar + /// (white in dark mode, black in light mode), aligning with the system tray's aesthetic. + var useColoredIcon: Bool + static let `default` = AppSettings( refreshInterval: 60, hasNotificationsEnabled: true, @@ -37,7 +42,8 @@ struct AppSettings: Codable, Equatable, Sendable { isFirstLaunch: true, cachedOrganizationId: nil, isSonnetUsageShown: false, - iconStyle: .battery + iconStyle: .battery, + useColoredIcon: false ) enum CodingKeys: String, CodingKey { @@ -48,6 +54,41 @@ struct AppSettings: Codable, Equatable, Sendable { case cachedOrganizationId = "cached_organization_id" case isSonnetUsageShown = "show_sonnet_usage" case iconStyle = "icon_style" + case useColoredIcon = "use_colored_icon" + } + + init( + refreshInterval: TimeInterval, + hasNotificationsEnabled: Bool, + notificationThresholds: NotificationThresholds, + isFirstLaunch: Bool, + cachedOrganizationId: UUID?, + isSonnetUsageShown: Bool, + iconStyle: IconStyle, + useColoredIcon: Bool + ) { + self.refreshInterval = refreshInterval + self.hasNotificationsEnabled = hasNotificationsEnabled + self.notificationThresholds = notificationThresholds + self.isFirstLaunch = isFirstLaunch + self.cachedOrganizationId = cachedOrganizationId + self.isSonnetUsageShown = isSonnetUsageShown + self.iconStyle = iconStyle + self.useColoredIcon = useColoredIcon + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let defaults = AppSettings.default + refreshInterval = try container.decodeIfPresent(TimeInterval.self, forKey: .refreshInterval) ?? defaults.refreshInterval + hasNotificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .hasNotificationsEnabled) ?? defaults.hasNotificationsEnabled + notificationThresholds = try container.decodeIfPresent(NotificationThresholds.self, forKey: .notificationThresholds) ?? defaults.notificationThresholds + isFirstLaunch = try container.decodeIfPresent(Bool.self, forKey: .isFirstLaunch) ?? defaults.isFirstLaunch + cachedOrganizationId = try container.decodeIfPresent(UUID.self, forKey: .cachedOrganizationId) + isSonnetUsageShown = try container.decodeIfPresent(Bool.self, forKey: .isSonnetUsageShown) ?? defaults.isSonnetUsageShown + iconStyle = try container.decodeIfPresent(IconStyle.self, forKey: .iconStyle) ?? defaults.iconStyle + // Backwards-compat: this key didn't exist before, so fall back to the default (off). + useColoredIcon = try container.decodeIfPresent(Bool.self, forKey: .useColoredIcon) ?? defaults.useColoredIcon } } diff --git a/ClaudeMeter/Views/MenuBar/IconCache.swift b/ClaudeMeter/Views/MenuBar/IconCache.swift index 48f67be..3dbbb5e 100644 --- a/ClaudeMeter/Views/MenuBar/IconCache.swift +++ b/ClaudeMeter/Views/MenuBar/IconCache.swift @@ -21,7 +21,8 @@ final class IconCache { isLoading: Bool, isStale: Bool, iconStyle: IconStyle, - weeklyPercentage: Double + weeklyPercentage: Double, + useColor: Bool ) -> NSImage? { cache.object(forKey: cacheKey( percentage: percentage, @@ -29,7 +30,8 @@ final class IconCache { isLoading: isLoading, isStale: isStale, iconStyle: iconStyle, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + useColor: useColor )) } @@ -40,7 +42,8 @@ final class IconCache { isLoading: Bool, isStale: Bool, iconStyle: IconStyle, - weeklyPercentage: Double + weeklyPercentage: Double, + useColor: Bool ) { cache.setObject( image, @@ -50,7 +53,8 @@ final class IconCache { isLoading: isLoading, isStale: isStale, iconStyle: iconStyle, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + useColor: useColor ) ) } @@ -61,10 +65,11 @@ final class IconCache { isLoading: Bool, isStale: Bool, iconStyle: IconStyle, - weeklyPercentage: Double + weeklyPercentage: Double, + useColor: Bool ) -> NSString { let percent = String(format: "%.2f", percentage) let weekly = String(format: "%.2f", weeklyPercentage) - return "\(percent)|\(weekly)|\(status.rawValue)|\(isLoading)|\(isStale)|\(iconStyle.rawValue)" as NSString + return "\(percent)|\(weekly)|\(status.rawValue)|\(isLoading)|\(isStale)|\(iconStyle.rawValue)|\(useColor)" as NSString } } diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/BatteryIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/BatteryIcon.swift index dc3bbaa..2058b12 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/BatteryIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/BatteryIcon.swift @@ -13,6 +13,7 @@ struct BatteryIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var useColor: Bool = true private let capsuleWidth: CGFloat = 28 private let capsuleHeight: CGFloat = 10 @@ -22,14 +23,14 @@ struct BatteryIcon: View { if isLoading { Image(systemName: "arrow.clockwise") .font(.system(size: 10, weight: .medium)) - .foregroundColor(statusColor) + .foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale)) } else { // Capsule with gradient fill using mask for proper rounded ends Capsule() - .fill(Color.gray.opacity(0.3)) + .fill(MenuBarIconColors.track(useColor: useColor)) .overlay(alignment: .leading) { GeometryReader { geo in - fillGradient + MenuBarIconColors.gradient(useColor: useColor, isStale: isStale) .frame(width: geo.size.width * min(percentage / 100, 1.0)) } .clipShape(Capsule()) @@ -37,9 +38,9 @@ struct BatteryIcon: View { .frame(width: capsuleWidth, height: capsuleHeight) // Percentage text - Text("\(Int(percentage))%") + Text("\(Int(percentage))%") .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundColor(statusColor) + .foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale)) } if isStale && !isLoading { @@ -53,27 +54,6 @@ struct BatteryIcon: View { .accessibilityLabel("Usage: \(Int(percentage)) percent") .accessibilityValue(status.accessibilityDescription) } - - private var fillGradient: LinearGradient { - if isStale { - return LinearGradient( - colors: [.gray, .gray], - startPoint: .leading, - endPoint: .trailing - ) - } - - // Gradient shows current status position - return LinearGradient( - colors: [.green, .yellow, .orange, .red], - startPoint: .leading, - endPoint: .trailing - ) - } - - private var statusColor: Color { - isStale ? .gray : status.color - } } #Preview { @@ -84,6 +64,7 @@ struct BatteryIcon: View { BatteryIcon(percentage: 95, status: .critical, isLoading: false, isStale: false) BatteryIcon(percentage: 45, status: .safe, isLoading: true, isStale: false) BatteryIcon(percentage: 45, status: .safe, isLoading: false, isStale: true) + BatteryIcon(percentage: 75, status: .warning, isLoading: false, isStale: false, useColor: false) } .padding() } diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/CircularGaugeIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/CircularGaugeIcon.swift index 446750b..b11428b 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/CircularGaugeIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/CircularGaugeIcon.swift @@ -13,6 +13,7 @@ struct CircularGaugeIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var useColor: Bool = true private let lineWidth: CGFloat = 3 private let size: CGFloat = 18 @@ -21,23 +22,23 @@ struct CircularGaugeIcon: View { ZStack { // Background circle Circle() - .stroke(Color.gray.opacity(0.3), lineWidth: lineWidth) + .stroke(MenuBarIconColors.track(useColor: useColor), lineWidth: lineWidth) // Progress arc Circle() .trim(from: 0, to: min(percentage / 100, 1.0)) - .stroke(statusColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .stroke(MenuBarIconColors.fill(useColor: useColor, status: status, isStale: isStale), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) // Center percentage or loading indicator if isLoading { Image(systemName: "arrow.clockwise") .font(.system(size: 7, weight: .medium)) - .foregroundColor(statusColor) + .foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale)) } else { Text("\(Int(percentage))") .font(.system(size: 7, weight: .bold, design: .rounded)) - .foregroundColor(statusColor) + .foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale)) } } .frame(width: size, height: size) @@ -54,10 +55,6 @@ struct CircularGaugeIcon: View { .accessibilityLabel("Usage: \(Int(percentage)) percent") .accessibilityValue(status.accessibilityDescription) } - - private var statusColor: Color { - isStale ? .gray : status.color - } } #Preview { diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/DualBarIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/DualBarIcon.swift index 278704e..437cc9c 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/DualBarIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/DualBarIcon.swift @@ -14,6 +14,7 @@ struct DualBarIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var useColor: Bool = true private let barWidth: CGFloat = 32 private let barHeight: CGFloat = 5 @@ -24,23 +25,23 @@ struct DualBarIcon: View { if isLoading { Image(systemName: "arrow.clockwise") .font(.system(size: 11, weight: .medium)) - .foregroundColor(statusColor) + .foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale)) } else { // Two stacked progress bars VStack(spacing: barSpacing) { - // Session bar (top) - blue/cyan + // Session bar (top): status colour in colour mode, black in monochrome ProgressBar( percentage: percentage, - color: sessionBarColor, - isStale: isStale + color: MenuBarIconColors.fill(useColor: useColor, status: status, isStale: isStale), + useColor: useColor ) .frame(width: barWidth, height: barHeight) - // Weekly bar (bottom) - purple + // Weekly bar (bottom): purple in colour mode, black in monochrome ProgressBar( percentage: weeklyPercentage, - color: weeklyBarColor, - isStale: isStale + color: MenuBarIconColors.secondary(useColor: useColor, isStale: isStale), + useColor: useColor ) .frame(width: barWidth, height: barHeight) } @@ -48,7 +49,7 @@ struct DualBarIcon: View { // Show session percentage (primary metric) Text("\(Int(percentage))%") .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundColor(statusColor) + .foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale)) } if isStale && !isLoading { @@ -62,36 +63,20 @@ struct DualBarIcon: View { .accessibilityLabel("Session: \(Int(percentage)) percent, Weekly: \(Int(weeklyPercentage)) percent") .accessibilityValue(status.accessibilityDescription) } - - private var statusColor: Color { - isStale ? .gray : status.color - } - - private var sessionBarColor: Color { - if isStale { return .gray } - // Use status color for session bar - return status.color - } - - private var weeklyBarColor: Color { - if isStale { return .gray } - // Purple/violet for weekly to distinguish from session - return .purple - } } /// Individual progress bar component private struct ProgressBar: View { let percentage: Double let color: Color - let isStale: Bool + let useColor: Bool var body: some View { GeometryReader { geo in ZStack(alignment: .leading) { // Background RoundedRectangle(cornerRadius: 1.5) - .fill(Color.gray.opacity(0.3)) + .fill(MenuBarIconColors.track(useColor: useColor)) // Fill RoundedRectangle(cornerRadius: 1.5) diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/GaugeIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/GaugeIcon.swift index 539b15e..630fd7f 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/GaugeIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/GaugeIcon.swift @@ -13,18 +13,19 @@ struct GaugeIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var useColor: Bool = true var body: some View { HStack(spacing: 4) { if isLoading { Image(systemName: "arrow.clockwise") .font(.system(size: 14, weight: .medium)) - .foregroundColor(statusColor) + .foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale)) } else { Image(systemName: symbolName) .font(.system(size: 14, weight: .medium)) - .foregroundColor(statusColor) - .symbolRenderingMode(.hierarchical) + .foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale)) + .symbolRenderingMode(useColor ? .hierarchical : .monochrome) } if isStale && !isLoading { @@ -59,10 +60,6 @@ struct GaugeIcon: View { return "gauge.with.dots.needle.100percent" } } - - private var statusColor: Color { - isStale ? .gray : status.color - } } #Preview { diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/MinimalIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/MinimalIcon.swift index a55e0e9..6cb44fc 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/MinimalIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/MinimalIcon.swift @@ -13,17 +13,18 @@ struct MinimalIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var useColor: Bool = true var body: some View { HStack(spacing: 2) { if isLoading { Image(systemName: "arrow.clockwise") .font(.system(size: 11, weight: .medium)) - .foregroundColor(statusColor) + .foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale)) } else { Text("\(Int(percentage))%") .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .foregroundColor(statusColor) + .foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale)) } if isStale && !isLoading { @@ -37,10 +38,6 @@ struct MinimalIcon: View { .accessibilityLabel("Usage: \(Int(percentage)) percent") .accessibilityValue(status.accessibilityDescription) } - - private var statusColor: Color { - isStale ? .gray : status.color - } } #Preview { diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/SegmentedBarIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/SegmentedBarIcon.swift index 2f571e8..df9120a 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/SegmentedBarIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/SegmentedBarIcon.swift @@ -13,6 +13,7 @@ struct SegmentedBarIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var useColor: Bool = true private let segmentCount = 5 private let segmentWidth: CGFloat = 4 @@ -23,7 +24,7 @@ struct SegmentedBarIcon: View { if isLoading { Image(systemName: "arrow.clockwise") .font(.system(size: 11, weight: .medium)) - .foregroundColor(statusColor) + .foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale)) } else { HStack(spacing: segmentSpacing) { ForEach(0..= threshold - (100.0 / Double(segmentCount)) RoundedRectangle(cornerRadius: 1) - .fill(isActive ? segmentColor(for: index) : Color.gray.opacity(0.3)) + .fill(isActive ? segmentColor(for: index) : MenuBarIconColors.track(useColor: useColor)) .frame(width: segmentWidth, height: segmentHeight(for: index)) } } @@ -57,9 +58,8 @@ struct SegmentedBarIcon: View { } private func segmentColor(for index: Int) -> Color { - if isStale { - return .gray - } + if isStale { return .gray } + if !useColor { return .black } // Color segments by position to create a gradient effect (green → orange → red) // Uses Constants.Thresholds.Status for consistent color boundaries let segmentPercentage = Double(index + 1) / Double(segmentCount) * 100 @@ -71,10 +71,6 @@ struct SegmentedBarIcon: View { return .red } } - - private var statusColor: Color { - isStale ? .gray : status.color - } } #Preview { diff --git a/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift b/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift index cde2580..a5f4b65 100644 --- a/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift +++ b/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift @@ -17,7 +17,8 @@ struct MenuBarIconRenderer { isLoading: Bool, isStale: Bool, iconStyle: IconStyle, - weeklyPercentage: Double = 0 + weeklyPercentage: Double = 0, + useColor: Bool = true ) -> NSImage { let iconView = MenuBarIconView( percentage: percentage, @@ -25,7 +26,8 @@ struct MenuBarIconRenderer { isLoading: isLoading, isStale: isStale, iconStyle: iconStyle, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + useColor: useColor ) let renderer = ImageRenderer(content: iconView) @@ -38,7 +40,9 @@ struct MenuBarIconRenderer { ) ?? NSImage() } - nsImage.isTemplate = false + // In monochrome mode the menu bar handles tinting (white on dark menu bars, black on + // light) via template rendering. In colour mode the original status palette is preserved. + nsImage.isTemplate = !useColor return nsImage } } diff --git a/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift b/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift index a6993c6..de0ee02 100644 --- a/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift +++ b/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift @@ -15,25 +15,68 @@ struct MenuBarIconView: View { let isStale: Bool let iconStyle: IconStyle var weeklyPercentage: Double = 0 // Optional, used by dualBar style + /// When false, the icon is drawn with pure black so the rendered NSImage can be used as a + /// template image and tinted by macOS (white on dark menu bars, black on light). + var useColor: Bool = true var body: some View { switch iconStyle { case .battery: - BatteryIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale) + BatteryIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale, useColor: useColor) case .circular: - CircularGaugeIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale) + CircularGaugeIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale, useColor: useColor) case .minimal: - MinimalIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale) + MinimalIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale, useColor: useColor) case .segments: - SegmentedBarIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale) + SegmentedBarIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale, useColor: useColor) case .dualBar: - DualBarIcon(percentage: percentage, weeklyPercentage: weeklyPercentage, status: status, isLoading: isLoading, isStale: isStale) + DualBarIcon(percentage: percentage, weeklyPercentage: weeklyPercentage, status: status, isLoading: isLoading, isStale: isStale, useColor: useColor) case .gauge: - GaugeIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale) + GaugeIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale, useColor: useColor) } } } +/// Centralised colour resolution for the menu bar icons. +/// +/// In monochrome mode every drawn pixel is black so the resulting NSImage can be rendered as a +/// template image (`NSImage.isTemplate = true`) and tinted by the system menu bar — white on +/// dark menu bars, black on light. In colour mode the original status palette is preserved. +enum MenuBarIconColors { + static func text(useColor: Bool, status: UsageStatus, isStale: Bool) -> Color { + if isStale { return .gray } + return useColor ? status.color : .black + } + + static func fill(useColor: Bool, status: UsageStatus, isStale: Bool) -> Color { + if isStale { return .gray } + return useColor ? status.color : .black + } + + /// Track / background. Matches the original gray track in colour mode (snapshot tests + /// are pixel-perfect). In monochrome mode it uses a black track so template rendering + /// tints it correctly. + static func track(useColor: Bool) -> Color { + useColor ? Color.gray.opacity(0.3) : Color.black.opacity(0.3) + } + + static func gradient(useColor: Bool, isStale: Bool) -> LinearGradient { + if isStale { + return LinearGradient(colors: [.gray, .gray], startPoint: .leading, endPoint: .trailing) + } + if useColor { + return LinearGradient(colors: [.green, .yellow, .orange, .red], startPoint: .leading, endPoint: .trailing) + } + return LinearGradient(colors: [.black, .black], startPoint: .leading, endPoint: .trailing) + } + + /// Secondary accent (e.g. weekly bar in DualBar). Purple in colour mode, black in monochrome. + static func secondary(useColor: Bool, isStale: Bool) -> Color { + if isStale { return .gray } + return useColor ? .purple : .black + } +} + // MARK: - Preview #Preview("All Styles") { @@ -49,6 +92,19 @@ struct MenuBarIconView: View { .padding() } +#Preview("Monochrome") { + VStack(alignment: .leading, spacing: 12) { + ForEach(IconStyle.allCases) { style in + HStack { + Text(style.displayName) + .frame(width: 80, alignment: .leading) + MenuBarIconView(percentage: 65, status: .warning, isLoading: false, isStale: false, iconStyle: style, weeklyPercentage: 45, useColor: false) + } + } + } + .padding() +} + #Preview("Battery States") { VStack(spacing: 20) { MenuBarIconView(percentage: 35, status: .safe, isLoading: false, isStale: false, iconStyle: .battery) diff --git a/ClaudeMeter/Views/MenuBar/MenuBarManager.swift b/ClaudeMeter/Views/MenuBar/MenuBarManager.swift index 7276352..efab097 100644 --- a/ClaudeMeter/Views/MenuBar/MenuBarManager.swift +++ b/ClaudeMeter/Views/MenuBar/MenuBarManager.swift @@ -99,6 +99,7 @@ final class MenuBarManager { _ = appModel.usageData _ = appModel.isLoading _ = appModel.settings.iconStyle + _ = appModel.settings.useColoredIcon } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } @@ -117,6 +118,7 @@ final class MenuBarManager { let isStale = appModel.usageData?.isStale ?? false let isLoading = appModel.isLoading let style = appModel.settings.iconStyle + let useColor = appModel.settings.useColoredIcon if let cachedImage = iconCache.get( percentage: percentage, @@ -124,7 +126,8 @@ final class MenuBarManager { isLoading: isLoading, isStale: isStale, iconStyle: style, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + useColor: useColor ) { button.image = cachedImage return @@ -136,7 +139,8 @@ final class MenuBarManager { isLoading: isLoading, isStale: isStale, iconStyle: style, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + useColor: useColor ) iconCache.set( @@ -146,7 +150,8 @@ final class MenuBarManager { isLoading: isLoading, isStale: isStale, iconStyle: style, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + useColor: useColor ) button.image = image diff --git a/ClaudeMeter/Views/Settings/SettingsView.swift b/ClaudeMeter/Views/Settings/SettingsView.swift index 01b06b8..9648441 100644 --- a/ClaudeMeter/Views/Settings/SettingsView.swift +++ b/ClaudeMeter/Views/Settings/SettingsView.swift @@ -69,6 +69,7 @@ struct SettingsView: View { refreshIntervalSection sonnetUsageSection iconStyleSection + coloredIconSection launchAtLoginSection } } @@ -209,6 +210,28 @@ struct SettingsView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } + // MARK: - Colored Icon Section + + private var coloredIconSection: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Use Colored Status Icon") + .font(.subheadline) + Text("Tint the menu bar icon green/orange/red based on usage. When off, the icon adapts to the system menu bar tint (white in dark mode).") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Toggle("", isOn: $appModel.settings.useColoredIcon) + .labelsHidden() + } + .padding() + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + // MARK: - Launch at Login Section private var launchAtLoginSection: some View {