Skip to content
Merged
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
23 changes: 22 additions & 1 deletion ClaudeMeter/Models/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,18 @@ 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,
notificationThresholds: .default,
isFirstLaunch: true,
cachedOrganizationId: nil,
isSonnetUsageShown: false,
iconStyle: .battery
iconStyle: .battery,
isColoredIcon: true
)

enum CodingKeys: String, CodingKey {
Expand All @@ -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
}
}

Expand Down
17 changes: 11 additions & 6 deletions ClaudeMeter/Views/MenuBar/IconCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,17 @@ final class IconCache {
isLoading: Bool,
isStale: Bool,
iconStyle: IconStyle,
weeklyPercentage: Double
weeklyPercentage: Double,
isColored: Bool
) -> NSImage? {
cache.object(forKey: cacheKey(
percentage: percentage,
status: status,
isLoading: isLoading,
isStale: isStale,
iconStyle: iconStyle,
weeklyPercentage: weeklyPercentage
weeklyPercentage: weeklyPercentage,
isColored: isColored
))
}

Expand All @@ -40,7 +42,8 @@ final class IconCache {
isLoading: Bool,
isStale: Bool,
iconStyle: IconStyle,
weeklyPercentage: Double
weeklyPercentage: Double,
isColored: Bool
) {
cache.setObject(
image,
Expand All @@ -50,7 +53,8 @@ final class IconCache {
isLoading: isLoading,
isStale: isStale,
iconStyle: iconStyle,
weeklyPercentage: weeklyPercentage
weeklyPercentage: weeklyPercentage,
isColored: isColored
)
)
}
Expand All @@ -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
}
}
5 changes: 3 additions & 2 deletions ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,7 +39,7 @@ struct MenuBarIconRenderer {
) ?? NSImage()
}

nsImage.isTemplate = false
nsImage.isTemplate = !isColored
return nsImage
}
}
11 changes: 8 additions & 3 deletions ClaudeMeter/Views/MenuBar/MenuBarManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -117,14 +118,16 @@ 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,
status: status,
isLoading: isLoading,
isStale: isStale,
iconStyle: style,
weeklyPercentage: weeklyPercentage
weeklyPercentage: weeklyPercentage,
isColored: isColored
) {
button.image = cachedImage
return
Expand All @@ -136,7 +139,8 @@ final class MenuBarManager {
isLoading: isLoading,
isStale: isStale,
iconStyle: style,
weeklyPercentage: weeklyPercentage
weeklyPercentage: weeklyPercentage,
isColored: isColored
)

iconCache.set(
Expand All @@ -146,7 +150,8 @@ final class MenuBarManager {
isLoading: isLoading,
isStale: isStale,
iconStyle: style,
weeklyPercentage: weeklyPercentage
weeklyPercentage: weeklyPercentage,
isColored: isColored
)

button.image = image
Expand Down
25 changes: 20 additions & 5 deletions ClaudeMeter/Views/Settings/IconStylePicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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)
Expand Down
31 changes: 25 additions & 6 deletions ClaudeMeter/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
32 changes: 32 additions & 0 deletions ClaudeMeterTests/MenuBarIconRendererTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
23 changes: 23 additions & 0 deletions ClaudeMeterTests/SettingsRepositoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,36 @@ 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()

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)
Expand Down
Binary file modified docs/settings-general.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.