diff --git a/Sources/CodexBar/PreferencesMenuBarPane.swift b/Sources/CodexBar/PreferencesMenuBarPane.swift index 975fe4f65e..1d881733d9 100644 --- a/Sources/CodexBar/PreferencesMenuBarPane.swift +++ b/Sources/CodexBar/PreferencesMenuBarPane.swift @@ -3,13 +3,11 @@ import SwiftUI @MainActor struct MenuBarPane: View { - private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit - @State private var isOverviewProviderPopoverPresented = false @Bindable var settings: SettingsStore @Bindable var store: UsageStore - static func overviewProviderLimitText(limit: Int = Self.maxOverviewProviders) -> String { + static func overviewProviderLimitText(limit: Int) -> String { L("overview_choose_providers", String(limit)) } @@ -75,6 +73,25 @@ struct MenuBarPane: View { self.overviewProviderRow .disabled(!self.settings.mergeIcons) + + LabeledContent { + HStack(spacing: 4) { + Text("\(self.settings.mergedOverviewProviderLimit)") + .foregroundStyle(.secondary) + Stepper( + value: self.$settings.mergedOverviewProviderLimit, + in: 1...12) + { + EmptyView() + } + .labelsHidden() + } + } label: { + SettingsRowLabel( + L("overview_provider_limit_title"), + subtitle: L("overview_provider_limit_subtitle")) + } + .disabled(!self.settings.mergeIcons) } header: { Text(L("section_combined_icon")) } @@ -135,7 +152,7 @@ struct MenuBarPane: View { private var overviewProviderPopover: some View { VStack(alignment: .leading, spacing: 10) { - Text(Self.overviewProviderLimitText()) + Text(Self.overviewProviderLimitText(limit: self.settings.mergedOverviewProviderLimit)) .font(.headline) Text(L("overview_rows_follow_order")) .font(.footnote) @@ -156,7 +173,7 @@ struct MenuBarPane: View { .toggleStyle(.checkbox) .disabled( !self.overviewSelectedProviders.contains(provider) && - self.overviewSelectedProviders.count >= Self.maxOverviewProviders) + self.overviewSelectedProviders.count >= self.settings.mergedOverviewProviderLimit) } } } @@ -173,7 +190,7 @@ struct MenuBarPane: View { private var overviewSelectedProviders: [UsageProvider] { self.settings.resolvedMergedOverviewProviders( activeProviders: self.activeProvidersInOrder, - maxVisibleProviders: Self.maxOverviewProviders) + maxVisibleProviders: self.settings.mergedOverviewProviderLimit) } private var showsOverviewConfigureButton: Bool { @@ -195,12 +212,12 @@ struct MenuBarPane: View { provider: provider, isSelected: isSelected, activeProviders: self.activeProvidersInOrder, - maxVisibleProviders: Self.maxOverviewProviders) + maxVisibleProviders: self.settings.mergedOverviewProviderLimit) } private func reconcileOverviewSelection() { _ = self.settings.reconcileMergedOverviewSelectedProviders( activeProviders: self.activeProvidersInOrder, - maxVisibleProviders: Self.maxOverviewProviders) + maxVisibleProviders: self.settings.mergedOverviewProviderLimit) } } diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 5cc6b2defb..06c6df6ef5 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -583,6 +583,8 @@ "show_most_used_provider_subtitle" = "Menu bar auto-shows the provider closest to its rate limit."; "display_mode_title" = "Display mode"; "display_mode_subtitle" = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; +"overview_provider_limit_title" = "Overview tab limit"; +"overview_provider_limit_subtitle" = "How many providers can show at once (default 6)."; "show_quota_warning_markers_title" = "Show quota warning markers"; "show_quota_warning_markers_subtitle" = "Draw threshold tick marks on usage bars when quota warnings are configured."; "weekly_progress_work_days_title" = "Work days"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 0624723be2..7cf452d872 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -771,10 +771,10 @@ extension SettingsStore { get { Self.decodeProviders( self.mergedOverviewSelectedProvidersRaw, - maxCount: Self.mergedOverviewProviderLimit) + maxCount: self.mergedOverviewProviderLimit) } set { - let normalized = Self.normalizeProviders(newValue, maxCount: Self.mergedOverviewProviderLimit) + let normalized = Self.normalizeProviders(newValue, maxCount: self.mergedOverviewProviderLimit) self.mergedOverviewSelectedProvidersRaw = normalized.map(\.rawValue) } } @@ -814,9 +814,29 @@ extension SettingsStore { self.mergedOverviewSelectionEditedActiveProvidersRaw = nil } + /// User-configurable cap on how many providers render in the merged-icon Overview tab, + /// persisted through this store's own `userDefaults` (not `.standard` directly) so isolated + /// test stores can observe and reset it independently. Bumps + /// `mergedOverviewProviderLimitRevision` so an already-open Overview menu picks up the change. + var mergedOverviewProviderLimit: Int { + get { + // `userDefaults` is `@ObservationIgnored`, so reading it directly registers no + // dependency — SwiftUI would never re-render a Stepper/Text bound to this property. + // Reading the tracked revision counter first gives the getter an observable + // dependency, matching the technique already used in menuObservationToken. + _ = self.mergedOverviewProviderLimitRevision + let stored = self.userDefaults.integer(forKey: "mergedOverviewProviderLimit") + return stored > 0 ? stored : Self.mergedOverviewProviderLimitDefault + } + set { + self.userDefaults.set(max(1, newValue), forKey: "mergedOverviewProviderLimit") + self.mergedOverviewProviderLimitRevision &+= 1 + } + } + func resolvedMergedOverviewProviders( activeProviders: [UsageProvider], - maxVisibleProviders: Int = SettingsStore.mergedOverviewProviderLimit) -> [UsageProvider] + maxVisibleProviders: Int = SettingsStore.mergedOverviewProviderLimitDefault) -> [UsageProvider] { guard maxVisibleProviders > 0 else { return [] } let normalizedActive = Self.normalizeProviders(activeProviders) @@ -836,7 +856,7 @@ extension SettingsStore { @discardableResult func reconcileMergedOverviewSelectedProviders( activeProviders: [UsageProvider], - maxVisibleProviders: Int = SettingsStore.mergedOverviewProviderLimit) -> [UsageProvider] + maxVisibleProviders: Int = SettingsStore.mergedOverviewProviderLimitDefault) -> [UsageProvider] { guard maxVisibleProviders > 0 else { self.clearMergedOverviewSelectionPreference() @@ -873,7 +893,7 @@ extension SettingsStore { provider: UsageProvider, isSelected: Bool, activeProviders: [UsageProvider], - maxVisibleProviders: Int = SettingsStore.mergedOverviewProviderLimit) -> [UsageProvider] + maxVisibleProviders: Int = SettingsStore.mergedOverviewProviderLimitDefault) -> [UsageProvider] { guard maxVisibleProviders > 0 else { self.clearMergedOverviewSelectionPreference() diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index c8e05dfd69..04421bdffe 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -84,6 +84,7 @@ extension SettingsStore { _ = self.mergeIcons _ = self.switcherShowsIcons _ = self.mergedOverviewSelectedProviders + _ = self.mergedOverviewProviderLimitRevision _ = self.zaiAPIToken _ = self.syntheticAPIToken _ = self.codexCookieHeader diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 62e5dc17e1..0850ddcf08 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -188,7 +188,15 @@ enum CodexAccountMenuProjectionRevalidationResult: Equatable { @Observable final class SettingsStore { static let sharedDefaults = AppGroupSupport.sharedDefaults() - static let mergedOverviewProviderLimit = 6 + /// Fallback used only by default-parameter expressions below, which Swift requires to be + /// self-independent (no access to `self.userDefaults`). Every real call site instead reads + /// the persisted, per-store value through the `mergedOverviewProviderLimit` instance accessor + /// in SettingsStore+Defaults.swift, so isolated test stores observe and reset it correctly. + static let mergedOverviewProviderLimitDefault = 6 + /// Bumped whenever `mergedOverviewProviderLimit` changes through the instance accessor below, + /// so `menuObservationToken` can pick up the change on an already-open Overview menu — the + /// static var itself isn't `@Observable`-tracked. + var mergedOverviewProviderLimitRevision = 0 static let productionCodexAccountReconciliationSnapshotCacheInterval: TimeInterval = 2 static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index d2efd57f76..366009ea76 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -1358,7 +1358,7 @@ extension StatusItemController { let activeProviders = self.store.enabledProvidersForDisplay() let overviewProviders = self.settings.resolvedMergedOverviewProviders( activeProviders: activeProviders, - maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) + maxVisibleProviders: self.settings.mergedOverviewProviderLimit) if let highest = self.store.providerWithHighestUsage(candidateProviders: overviewProviders) { return highest.provider } @@ -1372,7 +1372,7 @@ extension StatusItemController { let enabledProviders = self.store.enabledProvidersForDisplay() let overviewProviders = self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, - maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) + maxVisibleProviders: self.settings.mergedOverviewProviderLimit) if let provider = overviewProviders.first(where: { self.store.isEnabled($0) }) { return provider } diff --git a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift index 7fa38c6071..ac0d33a9a2 100644 --- a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift +++ b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift @@ -128,7 +128,7 @@ extension StatusItemController { let activeProviders = self.store.enabledProvidersForDisplay() return self.settings.resolvedMergedOverviewProviders( activeProviders: activeProviders, - maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit).contains(.codex) + maxVisibleProviders: self.settings.mergedOverviewProviderLimit).contains(.codex) } func observeMenuBarTimeEnvironmentChanges() { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index f5d0eaa43c..c41ae03e93 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -8,7 +8,6 @@ import SwiftUI extension StatusItemController { static let menuCardBaseWidth: CGFloat = 310 - private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit static let overviewRowIdentifierPrefix = "overviewRow-" static let persistentRefreshMenuItemID = "persistentRefreshAction" private static let defaultMenuOpenRefreshDelay: Duration = .seconds(1.2) @@ -598,7 +597,7 @@ extension StatusItemController { private func addOverviewEmptyState(to menu: NSMenu, enabledProviders: [UsageProvider]) { let resolvedProviders = self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, - maxVisibleProviders: Self.maxOverviewProviders) + maxVisibleProviders: self.settings.mergedOverviewProviderLimit) let message = resolvedProviders.isEmpty ? L("No providers selected for Overview.") : L("No overview data available.") @@ -1102,7 +1101,7 @@ extension StatusItemController { private func includesOverviewTab(enabledProviders: [UsageProvider]) -> Bool { !self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, - maxVisibleProviders: Self.maxOverviewProviders).isEmpty + maxVisibleProviders: self.settings.mergedOverviewProviderLimit).isEmpty } private func resolvedSwitcherSelection( @@ -1240,7 +1239,7 @@ extension StatusItemController { { return self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, - maxVisibleProviders: Self.maxOverviewProviders) + maxVisibleProviders: self.settings.mergedOverviewProviderLimit) } if let provider = self.menuProvider(for: menu) diff --git a/Sources/CodexBar/StatusItemController+MenuLocalization.swift b/Sources/CodexBar/StatusItemController+MenuLocalization.swift index abad4320d1..dc10970982 100644 --- a/Sources/CodexBar/StatusItemController+MenuLocalization.swift +++ b/Sources/CodexBar/StatusItemController+MenuLocalization.swift @@ -33,6 +33,6 @@ extension StatusItemController { private func includesOverviewTab(for providers: [UsageProvider]) -> Bool { !self.settings.resolvedMergedOverviewProviders( activeProviders: providers, - maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit).isEmpty + maxVisibleProviders: self.settings.mergedOverviewProviderLimit).isEmpty } } diff --git a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift index bf3db110d3..4538b0ddfe 100644 --- a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift +++ b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift @@ -54,7 +54,7 @@ extension StatusItemController { if let mergedMenu = self.mergedMenu, menu !== mergedMenu { return false } let providers = self.settings.resolvedMergedOverviewProviders( activeProviders: self.store.enabledProvidersForDisplay(), - maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) + maxVisibleProviders: self.settings.mergedOverviewProviderLimit) return !providers.isEmpty && self.settings.mergedMenuLastSelectedWasOverview } } diff --git a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift index cc4c71e653..4e5057c720 100644 --- a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift +++ b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift @@ -45,7 +45,7 @@ extension StatusItemController { let includesOverview = !self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, - maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit).isEmpty + maxVisibleProviders: self.settings.mergedOverviewProviderLimit).isEmpty var selections = enabledProviders.map(ProviderSwitcherSelection.provider) if includesOverview { selections.insert(.overview, at: 0) diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index d02f09fe57..3126500b3d 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -118,7 +118,7 @@ struct PreferencesPaneSmokeTests { @Test func `overview provider limit text shows the configured maximum`() { - let text = MenuBarPane.overviewProviderLimitText() + let text = MenuBarPane.overviewProviderLimitText(limit: SettingsStore.mergedOverviewProviderLimitDefault) #expect(text.contains("6")) #expect(!text.contains("%@"))