diff --git a/ClaudeMeter/Models/AppSettings.swift b/ClaudeMeter/Models/AppSettings.swift index 2719c9c..3522384 100644 --- a/ClaudeMeter/Models/AppSettings.swift +++ b/ClaudeMeter/Models/AppSettings.swift @@ -30,6 +30,9 @@ struct AppSettings: Codable, Equatable, Sendable { /// Menu bar icon display style var iconStyle: IconStyle + /// Whether menu bar icons are shown in color instead of monochrome. + var isColoredIcon: Bool + static let `default` = AppSettings( refreshInterval: 60, hasNotificationsEnabled: true, @@ -37,7 +40,8 @@ struct AppSettings: Codable, Equatable, Sendable { isFirstLaunch: true, cachedOrganizationId: nil, isSonnetUsageShown: false, - iconStyle: .battery + iconStyle: .battery, + isColoredIcon: true ) enum CodingKeys: String, CodingKey { @@ -48,6 +52,23 @@ struct AppSettings: Codable, Equatable, Sendable { case cachedOrganizationId = "cached_organization_id" case isSonnetUsageShown = "show_sonnet_usage" case iconStyle = "icon_style" + case isColoredIcon = "is_colored_icon" + } +} + +extension AppSettings { + 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 + isColoredIcon = try container.decodeIfPresent(Bool.self, forKey: .isColoredIcon) ?? defaults.isColoredIcon } } diff --git a/ClaudeMeter/Views/MenuBar/IconCache.swift b/ClaudeMeter/Views/MenuBar/IconCache.swift index 48f67be..49c0e65 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, + isColored: 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, + isColored: isColored )) } @@ -40,7 +42,8 @@ final class IconCache { isLoading: Bool, isStale: Bool, iconStyle: IconStyle, - weeklyPercentage: Double + weeklyPercentage: Double, + isColored: Bool ) { cache.setObject( image, @@ -50,7 +53,8 @@ final class IconCache { isLoading: isLoading, isStale: isStale, iconStyle: iconStyle, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + isColored: isColored ) ) } @@ -61,10 +65,11 @@ final class IconCache { isLoading: Bool, isStale: Bool, iconStyle: IconStyle, - weeklyPercentage: Double + weeklyPercentage: Double, + isColored: 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)|\(isColored)" as NSString } } diff --git a/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift b/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift index cde2580..dd1f602 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, + isColored: Bool = true ) -> NSImage { let iconView = MenuBarIconView( percentage: percentage, @@ -38,7 +39,7 @@ struct MenuBarIconRenderer { ) ?? NSImage() } - nsImage.isTemplate = false + nsImage.isTemplate = !isColored return nsImage } } diff --git a/ClaudeMeter/Views/MenuBar/MenuBarManager.swift b/ClaudeMeter/Views/MenuBar/MenuBarManager.swift index 7276352..fbb6a6f 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.isColoredIcon } 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 isColored = appModel.settings.isColoredIcon if let cachedImage = iconCache.get( percentage: percentage, @@ -124,7 +126,8 @@ final class MenuBarManager { isLoading: isLoading, isStale: isStale, iconStyle: style, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + isColored: isColored ) { button.image = cachedImage return @@ -136,7 +139,8 @@ final class MenuBarManager { isLoading: isLoading, isStale: isStale, iconStyle: style, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + isColored: isColored ) iconCache.set( @@ -146,7 +150,8 @@ final class MenuBarManager { isLoading: isLoading, isStale: isStale, iconStyle: style, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + isColored: isColored ) button.image = image diff --git a/ClaudeMeter/Views/Settings/IconStylePicker.swift b/ClaudeMeter/Views/Settings/IconStylePicker.swift index 0d59984..5f075aa 100644 --- a/ClaudeMeter/Views/Settings/IconStylePicker.swift +++ b/ClaudeMeter/Views/Settings/IconStylePicker.swift @@ -10,6 +10,7 @@ import SwiftUI /// Visual grid picker for selecting menu bar icon style struct IconStylePicker: View { @Binding var selection: IconStyle + let isColored: Bool var onSelectionChanged: ((IconStyle) -> Void)? = nil private let columns = [ @@ -23,7 +24,8 @@ struct IconStylePicker: View { ForEach(IconStyle.allCases) { style in IconStyleCard( style: style, - isSelected: selection == style + isSelected: selection == style, + isColored: isColored ) .contentShape(Rectangle()) .onTapGesture { @@ -39,6 +41,7 @@ struct IconStylePicker: View { struct IconStyleCard: View { let style: IconStyle let isSelected: Bool + let isColored: Bool /// Preview percentages to show private let previewPercentage: Double = 65 @@ -84,14 +87,18 @@ struct IconStyleCard: View { } private var iconPreview: some View { - MenuBarIconView( + Image(nsImage: MenuBarIconRenderer().render( percentage: previewPercentage, status: previewStatus, isLoading: false, isStale: false, iconStyle: style, - weeklyPercentage: previewWeeklyPercentage - ) + weeklyPercentage: previewWeeklyPercentage, + isColored: isColored + )) + .renderingMode(isColored ? .original : .template) + .foregroundStyle(.primary) + .accessibilityHidden(true) } } @@ -100,13 +107,21 @@ struct IconStyleCard: View { #Preview { struct PreviewWrapper: View { @State private var selection: IconStyle = .battery + @State private var isColored: Bool = true var body: some View { VStack { Text("Selected: \(selection.displayName)") .padding() - IconStylePicker(selection: $selection) + Picker("Icon color", selection: $isColored) { + Text("Mono").tag(false) + Text("Color").tag(true) + } + .pickerStyle(.segmented) + .padding(.horizontal) + + IconStylePicker(selection: $selection, isColored: isColored) .padding() } .frame(width: 400) diff --git a/ClaudeMeter/Views/Settings/SettingsView.swift b/ClaudeMeter/Views/Settings/SettingsView.swift index 01b06b8..41d41ad 100644 --- a/ClaudeMeter/Views/Settings/SettingsView.swift +++ b/ClaudeMeter/Views/Settings/SettingsView.swift @@ -195,14 +195,33 @@ struct SettingsView: View { private var iconStyleSection: some View { VStack(alignment: .leading, spacing: 12) { - Text("Menu Bar Icon Style") - .font(.subheadline) + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("Menu Bar Icon Style") + .font(.subheadline) - Text("Choose how the usage indicator appears in your menu bar") - .font(.caption) - .foregroundStyle(.secondary) + Text("Choose how the usage indicator appears in menu bar") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Picker("Icon color", selection: $appModel.settings.isColoredIcon) { + Text("Mono").tag(false) + Text("Color").tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 136) + .help("Mono uses the standard system menu bar tint") + .accessibilityLabel("Icon color mode") + } - IconStylePicker(selection: $appModel.settings.iconStyle) + IconStylePicker( + selection: $appModel.settings.iconStyle, + isColored: appModel.settings.isColoredIcon + ) } .padding() .background(.quaternary.opacity(0.3)) diff --git a/ClaudeMeterTests/MenuBarIconRendererTests.swift b/ClaudeMeterTests/MenuBarIconRendererTests.swift index bc96d64..1c254f1 100644 --- a/ClaudeMeterTests/MenuBarIconRendererTests.swift +++ b/ClaudeMeterTests/MenuBarIconRendererTests.swift @@ -69,4 +69,36 @@ final class MenuBarIconRendererTests: XCTestCase { XCTAssertFalse(image.isTemplate) } + + func test_menuBarIconIsRenderedAsTemplateImageWhenMonochromeModeSelected() { + let renderer = MenuBarIconRenderer() + + let image = renderer.render( + percentage: TestConstants.sessionPercentage, + status: .safe, + isLoading: false, + isStale: false, + iconStyle: .battery, + weeklyPercentage: TestConstants.weeklyPercentage, + isColored: false + ) + + XCTAssertTrue(image.isTemplate) + } + + func test_menuBarIconIsRenderedAsNonTemplateImageWhenColorModeSelected() { + let renderer = MenuBarIconRenderer() + + let image = renderer.render( + percentage: TestConstants.sessionPercentage, + status: .safe, + isLoading: false, + isStale: false, + iconStyle: .battery, + weeklyPercentage: TestConstants.weeklyPercentage, + isColored: true + ) + + XCTAssertFalse(image.isTemplate) + } } diff --git a/ClaudeMeterTests/SettingsRepositoryTests.swift b/ClaudeMeterTests/SettingsRepositoryTests.swift index 7b7964d..ced7c51 100644 --- a/ClaudeMeterTests/SettingsRepositoryTests.swift +++ b/ClaudeMeterTests/SettingsRepositoryTests.swift @@ -21,6 +21,7 @@ final class SettingsRepositoryTests: XCTestCase { settings.isFirstLaunch = false settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString) settings.iconStyle = .segments + settings.isColoredIcon = false try await repository.save(settings) let loaded = await repository.load() @@ -28,6 +29,28 @@ final class SettingsRepositoryTests: XCTestCase { XCTAssertEqual(loaded, settings) } + func test_settingsDecodingWithoutIsColoredIcon_usesDefault() throws { + let data = """ + { + "refresh_interval": 300, + "notifications_enabled": false, + "notification_thresholds": { + "warning_threshold": 70, + "critical_threshold": 90, + "notify_on_reset": false + }, + "is_first_launch": false, + "cached_organization_id": null, + "show_sonnet_usage": true, + "icon_style": "segments" + } + """.data(using: .utf8)! + + let settings = try JSONDecoder().decode(AppSettings.self, from: data) + + XCTAssertTrue(settings.isColoredIcon) + } + func test_notificationStatePersistsAcrossLaunches() async throws { let suiteName = "SettingsRepositoryTests.\(UUID().uuidString)" let userDefaults = UserDefaults(suiteName: suiteName) diff --git a/docs/settings-general.png b/docs/settings-general.png index e1881e6..6e88b50 100644 Binary files a/docs/settings-general.png and b/docs/settings-general.png differ