diff --git a/CHANGELOG.md b/CHANGELOG.md index ec33a5f7..1c3eec02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ - Preferences: add per-provider menu bar metric picker (#185) — thanks @HaukeSchnau - Preferences: tighten provider rows (inline pickers, compact layout, inline refresh + auto-source status). - Preferences: remove the “experimental” label from Antigravity. +- Menu bar: add display mode picker for percent/pace/both in the menu bar icon (#169). Thanks @PhilETaylor! - Menu bar: fix combined loading indicator flicker during loading animation (incl. debug replay). - Menu bar: prevent blink updates from clobbering the loading animation. diff --git a/Sources/CodexBar/MenuBarDisplayMode.swift b/Sources/CodexBar/MenuBarDisplayMode.swift new file mode 100644 index 00000000..f0731747 --- /dev/null +++ b/Sources/CodexBar/MenuBarDisplayMode.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Controls what the menu bar displays when brand icon mode is enabled. +enum MenuBarDisplayMode: String, CaseIterable, Identifiable { + case percent + case pace + case both + + var id: String { self.rawValue } + + var label: String { + switch self { + case .percent: "Percent" + case .pace: "Pace" + case .both: "Both" + } + } + + var description: String { + switch self { + case .percent: "Show remaining/used percentage (e.g. 45%)" + case .pace: "Show pace indicator (e.g. +5%)" + case .both: "Show both percentage and pace (e.g. 45% · +5%)" + } + } +} diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 7dfdfeb5..ab45ec0e 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -67,6 +67,26 @@ struct AdvancedPane: View { title: "Menu bar shows percent", subtitle: "Replace critter bars with provider branding icons and a percentage.", binding: self.$settings.menuBarShowsBrandIconWithPercent) + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Display mode") + .font(.body) + Text("Choose what to show in the menu bar (Pace shows usage vs. expected).") + .font(.footnote) + .foregroundStyle(.tertiary) + } + Spacer() + Picker("Display mode", selection: self.$settings.menuBarDisplayMode) { + ForEach(MenuBarDisplayMode.allCases) { mode in + Text(mode.label).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } + .disabled(!self.settings.menuBarShowsBrandIconWithPercent) + .opacity(self.settings.menuBarShowsBrandIconWithPercent ? 1 : 0.5) PreferenceToggleRow( title: "Show all token accounts", subtitle: "Stack token accounts in the menu (otherwise show an account switcher bar).", diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index df4dc3ee..0275c81b 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -12,6 +12,7 @@ extension SettingsStore { _ = self.usageBarsShowUsed _ = self.resetTimesShowAbsolute _ = self.menuBarShowsBrandIconWithPercent + _ = self.menuBarDisplayMode _ = self.showAllTokenAccountsInMenu _ = self.menuBarMetricPreferencesRaw _ = self.costUsageEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 21d7859b..0e527671 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -125,6 +125,17 @@ final class SettingsStore { } } + /// Controls what the menu bar displays when brand icon mode is enabled. + private var menuBarDisplayModeRaw: String? { + didSet { + if let raw = self.menuBarDisplayModeRaw { + self.userDefaults.set(raw, forKey: "menuBarDisplayMode") + } else { + self.userDefaults.removeObject(forKey: "menuBarDisplayMode") + } + } + } + /// Optional: show all token accounts stacked in the menu (otherwise show a switcher bar). var showAllTokenAccountsInMenu: Bool { didSet { self.userDefaults.set(self.showAllTokenAccountsInMenu, forKey: "showAllTokenAccountsInMenu") } @@ -669,6 +680,8 @@ final class SettingsStore { self.resetTimesShowAbsolute = userDefaults.object(forKey: "resetTimesShowAbsolute") as? Bool ?? false self.menuBarShowsBrandIconWithPercent = userDefaults.object( forKey: "menuBarShowsBrandIconWithPercent") as? Bool ?? false + self.menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode") + ?? MenuBarDisplayMode.percent.rawValue self.showAllTokenAccountsInMenu = userDefaults.object( forKey: "showAllTokenAccountsInMenu") as? Bool ?? false let storedMenuBarMetricPreferences = userDefaults.dictionary( @@ -1653,6 +1666,13 @@ extension SettingsStore { } } +extension SettingsStore { + var menuBarDisplayMode: MenuBarDisplayMode { + get { MenuBarDisplayMode(rawValue: self.menuBarDisplayModeRaw ?? "") ?? .percent } + set { self.menuBarDisplayModeRaw = newValue.rawValue } + } +} + enum LaunchAtLoginManager { @MainActor static func setEnabled(_ enabled: Bool) { diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index c25dc87c..357aef45 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -236,9 +236,9 @@ extension StatusItemController { if showBrandPercent, let brand = ProviderBrandIcon.image(for: primaryProvider) { - let percentText = self.menuBarPercentText(for: primaryProvider, snapshot: snapshot) + let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot) self.setButtonImage(brand, for: button) - self.setButtonTitle(percentText, for: button) + self.setButtonTitle(displayText, for: button) return } @@ -272,9 +272,9 @@ extension StatusItemController { if showBrandPercent, let brand = ProviderBrandIcon.image(for: provider) { - let percentText = self.menuBarPercentText(for: provider, snapshot: snapshot) + let displayText = self.menuBarDisplayText(for: provider, snapshot: snapshot) self.setButtonImage(brand, for: button) - self.setButtonTitle(percentText, for: button) + self.setButtonTitle(displayText, for: button) return } var primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent @@ -349,6 +349,34 @@ extension StatusItemController { return String(format: "%.0f%%", clamped) } + private func menuBarPaceText(for provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { + // PACE is calculated from the weekly (secondary) window, not the session (primary) window. + guard let window = snapshot?.secondary else { return nil } + guard let pace = UsagePaceText.weeklyPace(provider: provider, window: window, now: Date()) else { return nil } + + let deltaValue = Int(abs(pace.deltaPercent).rounded()) + let sign = pace.deltaPercent >= 0 ? "+" : "-" + return "\(sign)\(deltaValue)%" + } + + func menuBarDisplayText(for provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { + let mode = self.settings.menuBarDisplayMode + + switch mode { + case .percent: + return self.menuBarPercentText(for: provider, snapshot: snapshot) + case .pace: + return self.menuBarPaceText(for: provider, snapshot: snapshot) + case .both: + let percentText = self.menuBarPercentText(for: provider, snapshot: snapshot) + let paceText = self.menuBarPaceText(for: provider, snapshot: snapshot) + if let percent = percentText, let pace = paceText { + return "\(percent) · \(pace)" + } + return nil + } + } + private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { self.menuBarMetricWindow(for: provider, snapshot: snapshot) } diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index d289c03e..920e38ef 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -56,7 +56,7 @@ enum UsagePaceText { return countdown } - private static func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { + static func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { guard provider == .codex || provider == .claude else { return nil } guard window.remainingPercent > 0 else { return nil } guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) else { return nil }