1450 lines
60 KiB
Swift
1450 lines
60 KiB
Swift
import AppKit
|
|
import CodexBarCore
|
|
import QuartzCore
|
|
|
|
extension StatusItemController {
|
|
private static let loadingPercentEpsilon = 0.0001
|
|
private static let blinkActiveTickInterval: Duration = .milliseconds(75)
|
|
private static let blinkIdleFallbackInterval: Duration = .seconds(1)
|
|
static let loadingAnimationFPS: Double = 30.0
|
|
static let loadingAnimationPhaseIncrement: Double =
|
|
2.7 / StatusItemController.loadingAnimationFPS
|
|
private static let loadingAnimationMaxContinuousDuration: TimeInterval = 30.0
|
|
func needsMenuBarIconAnimation() -> Bool {
|
|
if self.shouldMergeIcons {
|
|
let primaryProvider = self.primaryProviderForUnifiedIcon()
|
|
return self.shouldAnimate(provider: primaryProvider)
|
|
}
|
|
return UsageProvider.allCases.contains { self.shouldAnimate(provider: $0) }
|
|
}
|
|
|
|
func updateBlinkingState() {
|
|
#if DEBUG
|
|
guard !self.isReleasedForTesting else { return }
|
|
#endif
|
|
// During the loading animation, blink ticks can overwrite the animated menu bar icon and cause flicker.
|
|
if self.needsMenuBarIconAnimation() {
|
|
self.stopBlinking()
|
|
return
|
|
}
|
|
|
|
let blinkingEnabled = self.isBlinkingAllowed()
|
|
// Use display list so merged-mode visibility stays consistent with shouldMergeIcons.
|
|
let displayProviders = self.store.enabledProvidersForDisplay()
|
|
let anyEnabled = !displayProviders.isEmpty || self.store.debugForceAnimation
|
|
let anyVisible = UsageProvider.allCases.contains { self.isVisible($0) }
|
|
let mergeIcons = self.shouldMergeIcons
|
|
let shouldBlink = mergeIcons ? anyEnabled : anyVisible
|
|
if blinkingEnabled, shouldBlink {
|
|
if self.blinkTask == nil {
|
|
self.seedBlinkStatesIfNeeded()
|
|
self.blinkTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
let delay = await MainActor.run {
|
|
self?.blinkTickSleepDuration(now: Date())
|
|
?? Self.blinkIdleFallbackInterval
|
|
}
|
|
try? await Task.sleep(for: delay)
|
|
await MainActor.run { self?.tickBlink() }
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
self.stopBlinking()
|
|
}
|
|
}
|
|
|
|
private func seedBlinkStatesIfNeeded() {
|
|
let now = Date()
|
|
for provider in UsageProvider.allCases where self.blinkStates[provider] == nil {
|
|
self.blinkStates[provider] = BlinkState(
|
|
nextBlink: now.addingTimeInterval(BlinkState.randomDelay()))
|
|
}
|
|
}
|
|
|
|
private func stopBlinking() {
|
|
self.blinkTask?.cancel()
|
|
self.blinkTask = nil
|
|
self.blinkAmounts.removeAll()
|
|
let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil
|
|
if self.shouldMergeIcons {
|
|
self.applyIcon(phase: phase)
|
|
} else {
|
|
for provider in UsageProvider.allCases {
|
|
self.applyIcon(for: provider, phase: phase)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func blinkTickSleepDuration(now: Date) -> Duration {
|
|
let mergeIcons = self.shouldMergeIcons
|
|
var nextWakeAt: Date?
|
|
|
|
for provider in UsageProvider.allCases {
|
|
let shouldRender = mergeIcons ? self.isEnabled(provider) : self.isVisible(provider)
|
|
guard shouldRender, !self.shouldAnimate(provider: provider, mergeIcons: mergeIcons)
|
|
else { continue }
|
|
|
|
let state =
|
|
self
|
|
.blinkStates[provider]
|
|
?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay()))
|
|
if state.blinkStart != nil {
|
|
return Self.blinkActiveTickInterval
|
|
}
|
|
|
|
let candidate: Date = state.pendingSecondStart ?? state.nextBlink
|
|
if let current = nextWakeAt {
|
|
if candidate < current {
|
|
nextWakeAt = candidate
|
|
}
|
|
} else {
|
|
nextWakeAt = candidate
|
|
}
|
|
}
|
|
|
|
guard let nextWakeAt else { return Self.blinkIdleFallbackInterval }
|
|
let delay = nextWakeAt.timeIntervalSince(now)
|
|
if delay <= 0 { return Self.blinkActiveTickInterval }
|
|
return .seconds(delay)
|
|
}
|
|
|
|
private func tickBlink(now: Date = .init()) {
|
|
guard self.isBlinkingAllowed(at: now) else {
|
|
self.stopBlinking()
|
|
return
|
|
}
|
|
|
|
let blinkDuration: TimeInterval = 0.36
|
|
let doubleBlinkChance = 0.18
|
|
let doubleDelayRange: ClosedRange<TimeInterval> = 0.22...0.34
|
|
// Cache merge state once per tick to avoid repeated enabled-provider lookups.
|
|
let mergeIcons = self.shouldMergeIcons
|
|
|
|
for provider in UsageProvider.allCases {
|
|
let shouldRender = mergeIcons ? self.isEnabled(provider) : self.isVisible(provider)
|
|
guard shouldRender, !self.shouldAnimate(provider: provider, mergeIcons: mergeIcons)
|
|
else {
|
|
self.clearMotion(for: provider)
|
|
continue
|
|
}
|
|
|
|
var state =
|
|
self
|
|
.blinkStates[provider]
|
|
?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay()))
|
|
|
|
if let pendingSecond = state.pendingSecondStart, now >= pendingSecond {
|
|
state.blinkStart = now
|
|
state.pendingSecondStart = nil
|
|
}
|
|
|
|
if let start = state.blinkStart {
|
|
let elapsed = now.timeIntervalSince(start)
|
|
if elapsed >= blinkDuration {
|
|
state.blinkStart = nil
|
|
if let pending = state.pendingSecondStart, now < pending {
|
|
// Wait for the planned double-blink.
|
|
} else {
|
|
state.pendingSecondStart = nil
|
|
state.nextBlink = now.addingTimeInterval(BlinkState.randomDelay())
|
|
}
|
|
self.clearMotion(for: provider)
|
|
} else {
|
|
let progress = max(0, min(elapsed / blinkDuration, 1))
|
|
let symmetric = progress < 0.5 ? progress * 2 : (1 - progress) * 2
|
|
let eased = pow(symmetric, 2.2) // slightly punchier than smoothstep
|
|
self.assignMotion(amount: CGFloat(eased), for: provider, effect: state.effect)
|
|
}
|
|
} else if now >= state.nextBlink {
|
|
state.blinkStart = now
|
|
state.effect = self.randomEffect(for: provider)
|
|
if state.effect == .blink, Double.random(in: 0...1) < doubleBlinkChance {
|
|
state.pendingSecondStart = now.addingTimeInterval(
|
|
Double.random(in: doubleDelayRange))
|
|
}
|
|
self.clearMotion(for: provider)
|
|
} else {
|
|
self.clearMotion(for: provider)
|
|
}
|
|
|
|
self.blinkStates[provider] = state
|
|
if !mergeIcons {
|
|
self.applyIcon(for: provider, phase: nil)
|
|
}
|
|
}
|
|
if mergeIcons {
|
|
self.applyIcon(phase: nil)
|
|
}
|
|
}
|
|
|
|
private func blinkAmount(for provider: UsageProvider) -> CGFloat {
|
|
guard self.isBlinkingAllowed() else { return 0 }
|
|
return self.blinkAmounts[provider] ?? 0
|
|
}
|
|
|
|
private func wiggleAmount(for provider: UsageProvider) -> CGFloat {
|
|
guard self.isBlinkingAllowed() else { return 0 }
|
|
return self.wiggleAmounts[provider] ?? 0
|
|
}
|
|
|
|
private func tiltAmount(for provider: UsageProvider) -> CGFloat {
|
|
guard self.isBlinkingAllowed() else { return 0 }
|
|
return self.tiltAmounts[provider] ?? 0
|
|
}
|
|
|
|
private func assignMotion(amount: CGFloat, for provider: UsageProvider, effect: MotionEffect) {
|
|
switch effect {
|
|
case .blink:
|
|
self.blinkAmounts[provider] = amount
|
|
self.wiggleAmounts[provider] = 0
|
|
self.tiltAmounts[provider] = 0
|
|
case .wiggle:
|
|
self.wiggleAmounts[provider] = amount
|
|
self.blinkAmounts[provider] = 0
|
|
self.tiltAmounts[provider] = 0
|
|
case .tilt:
|
|
self.tiltAmounts[provider] = amount
|
|
self.blinkAmounts[provider] = 0
|
|
self.wiggleAmounts[provider] = 0
|
|
}
|
|
}
|
|
|
|
private func clearMotion(for provider: UsageProvider) {
|
|
self.blinkAmounts[provider] = 0
|
|
self.wiggleAmounts[provider] = 0
|
|
self.tiltAmounts[provider] = 0
|
|
}
|
|
|
|
private func randomEffect(for provider: UsageProvider) -> MotionEffect {
|
|
if provider == .claude {
|
|
Bool.random() ? .blink : .wiggle
|
|
} else {
|
|
Bool.random() ? .blink : .tilt
|
|
}
|
|
}
|
|
|
|
private func isBlinkingAllowed(at date: Date = .init()) -> Bool {
|
|
if self.settings.randomBlinkEnabled { return true }
|
|
if let until = self.blinkForceUntil, until > date { return true }
|
|
self.blinkForceUntil = nil
|
|
return false
|
|
}
|
|
|
|
@discardableResult
|
|
func applyIcon(
|
|
phase: Double?,
|
|
bypassMergedMenuTrackingDeferral: Bool = false) -> Bool
|
|
{
|
|
guard let button = self.statusItem.button else { return false }
|
|
if !bypassMergedMenuTrackingDeferral,
|
|
self.deferMergedIconRenderDuringMenuTrackingIfNeeded() { return true }
|
|
|
|
let style = self.store.iconStyle
|
|
let showUsed = self.settings.usageBarsShowUsed
|
|
let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent
|
|
let primaryProvider = self.primaryProviderForUnifiedIcon()
|
|
let resolverStyle = self.store.style(for: primaryProvider)
|
|
let snapshot = self.store.snapshot(for: primaryProvider)
|
|
let warningFlash = self.quotaWarningFlashActive(provider: primaryProvider)
|
|
|
|
// IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the
|
|
// user setting we pass either "percent left" or "percent used".
|
|
let resolved = self.resolvedMenuBarIconPercents(
|
|
provider: primaryProvider,
|
|
snapshot: snapshot,
|
|
style: resolverStyle,
|
|
showUsed: showUsed,
|
|
renderingStyle: style)
|
|
var primary = resolved?.primary
|
|
var weekly = resolved?.secondary
|
|
var credits = self.menuBarCreditsRemainingForIcon(provider: primaryProvider, snapshot: snapshot)
|
|
var stale = self.store.isStale(provider: primaryProvider)
|
|
var morphProgress: Double?
|
|
|
|
let needsAnimation = self.needsMenuBarIconAnimation()
|
|
if let phase, needsAnimation {
|
|
var pattern = self.animationPattern
|
|
if style == .combined, pattern == .unbraid {
|
|
pattern = .cylon
|
|
}
|
|
if pattern == .unbraid {
|
|
morphProgress = pattern.value(phase: phase) / 100
|
|
primary = nil
|
|
weekly = nil
|
|
credits = nil
|
|
stale = false
|
|
} else {
|
|
// Keep loading animation layout stable: IconRenderer uses `weeklyRemaining > 0` to switch layouts,
|
|
// so hitting an exact 0 would flip between "normal" and "weekly exhausted" rendering.
|
|
primary = max(pattern.value(phase: phase), Self.loadingPercentEpsilon)
|
|
weekly = max(
|
|
pattern.value(phase: phase + pattern.secondaryOffset),
|
|
Self.loadingPercentEpsilon)
|
|
credits = nil
|
|
stale = false
|
|
}
|
|
}
|
|
|
|
let blink: CGFloat = style == .combined ? 0 : self.blinkAmount(for: primaryProvider)
|
|
let wiggle: CGFloat = style == .combined ? 0 : self.wiggleAmount(for: primaryProvider)
|
|
let tilt: CGFloat =
|
|
style == .combined ? 0 : self.tiltAmount(for: primaryProvider) * .pi / 28
|
|
|
|
let statusIndicator = self.store.statusIndicator(for: primaryProvider)
|
|
if showBrandPercent,
|
|
let brand = ProviderBrandIcon.image(for: primaryProvider)
|
|
{
|
|
let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot)
|
|
let signature = [
|
|
"mode=brandPercent",
|
|
"provider=\(primaryProvider.rawValue)",
|
|
"style=\(String(describing: style))",
|
|
"primary=\(Self.iconSignatureValue(primary))",
|
|
"weekly=\(Self.iconSignatureValue(weekly))",
|
|
"credits=\(Self.iconSignatureValue(credits))",
|
|
"stale=\(stale ? "1" : "0")",
|
|
"status=\(statusIndicator.rawValue)",
|
|
"text=\(displayText ?? "nil")",
|
|
"warningFlash=\(warningFlash ? "1" : "0")",
|
|
"anim=\(needsAnimation ? "1" : "0")",
|
|
"hideCritters=\(self.settings.menuBarHidesCritters ? "1" : "0")",
|
|
].joined(separator: "|")
|
|
if self.shouldSkipMergedIconRender(signature) {
|
|
// AppKit can lose button title/image-position state independently of the cached render signature.
|
|
// Keep the cheap title path self-healing even when the icon image itself can be skipped.
|
|
self.setButtonTitle(displayText, for: button)
|
|
self.noteIconPerfRender(skipped: true)
|
|
return true
|
|
}
|
|
self.setButtonImage(
|
|
warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button)
|
|
self.setButtonTitle(displayText, for: button)
|
|
self.noteIconPerfRender(skipped: false)
|
|
return false
|
|
}
|
|
|
|
self.setButtonTitle(nil, for: button)
|
|
if let morphProgress {
|
|
let signature = [
|
|
"mode=morph",
|
|
"provider=\(primaryProvider.rawValue)",
|
|
"style=\(String(describing: style))",
|
|
"morph=\(Self.iconSignatureValue(morphProgress))",
|
|
"status=\(statusIndicator.rawValue)",
|
|
"warningFlash=\(warningFlash ? "1" : "0")",
|
|
"anim=\(needsAnimation ? "1" : "0")",
|
|
"hideCritters=\(self.settings.menuBarHidesCritters ? "1" : "0")",
|
|
].joined(separator: "|")
|
|
if self.shouldSkipMergedIconRender(signature) {
|
|
self.noteIconPerfRender(skipped: true)
|
|
return true
|
|
}
|
|
let image = IconRenderer.makeMorphIcon(
|
|
progress: morphProgress,
|
|
style: style,
|
|
hideCritters: self.settings.menuBarHidesCritters)
|
|
self.setButtonImage(
|
|
warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button)
|
|
} else {
|
|
let signature = [
|
|
"mode=icon",
|
|
"provider=\(primaryProvider.rawValue)",
|
|
"style=\(String(describing: style))",
|
|
"primary=\(Self.iconSignatureValue(primary))",
|
|
"weekly=\(Self.iconSignatureValue(weekly))",
|
|
"credits=\(Self.iconSignatureValue(credits))",
|
|
"stale=\(stale ? "1" : "0")",
|
|
"status=\(statusIndicator.rawValue)",
|
|
"blink=\(Self.iconSignatureValue(Double(blink)))",
|
|
"wiggle=\(Self.iconSignatureValue(Double(wiggle)))",
|
|
"tilt=\(Self.iconSignatureValue(Double(tilt)))",
|
|
"warningFlash=\(warningFlash ? "1" : "0")",
|
|
"anim=\(needsAnimation ? "1" : "0")",
|
|
"hideCritters=\(self.settings.menuBarHidesCritters ? "1" : "0")",
|
|
].joined(separator: "|")
|
|
if self.shouldSkipMergedIconRender(signature) {
|
|
self.noteIconPerfRender(skipped: true)
|
|
return true
|
|
}
|
|
let image = IconRenderer.makeIcon(
|
|
primaryRemaining: primary,
|
|
weeklyRemaining: weekly,
|
|
creditsRemaining: credits,
|
|
stale: stale,
|
|
style: style,
|
|
blink: blink,
|
|
wiggle: wiggle,
|
|
tilt: tilt,
|
|
statusIndicator: statusIndicator,
|
|
hideCritters: self.settings.menuBarHidesCritters)
|
|
self.setButtonImage(
|
|
warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button)
|
|
}
|
|
self.noteIconPerfRender(skipped: false)
|
|
return false
|
|
}
|
|
|
|
private func deferMergedIconRenderDuringMenuTrackingIfNeeded() -> Bool {
|
|
guard self.shouldMergeIcons, self.isMergedMenuOpen else { return false }
|
|
self.deferredMergedIconRenderAfterTracking = true
|
|
self.noteIconPerfRender(skipped: true)
|
|
return true
|
|
}
|
|
|
|
func applyDeferredMergedIconRenderAfterTrackingIfNeeded() {
|
|
guard self.deferredMergedIconRenderAfterTracking else { return }
|
|
guard self.shouldMergeIcons else {
|
|
self.deferredMergedIconRenderAfterTracking = false
|
|
return
|
|
}
|
|
guard !self.isMergedMenuOpen else { return }
|
|
self.deferredMergedIconRenderAfterTracking = false
|
|
let phase: Double? = self.animationDriver == nil ? nil : self.animationPhase
|
|
self.applyIcon(phase: phase)
|
|
}
|
|
|
|
private func shouldSkipMergedIconRender(_ signature: String) -> Bool {
|
|
guard self.shouldMergeIcons else {
|
|
self.lastAppliedMergedIconRenderSignature = signature
|
|
return false
|
|
}
|
|
if self.lastAppliedMergedIconRenderSignature == signature {
|
|
return true
|
|
}
|
|
self.lastAppliedMergedIconRenderSignature = signature
|
|
return false
|
|
}
|
|
|
|
private func shouldSkipProviderIconRender(provider: UsageProvider, signature: String) -> Bool {
|
|
if self.lastAppliedProviderIconRenderSignatures[provider] == signature {
|
|
return true
|
|
}
|
|
self.lastAppliedProviderIconRenderSignatures[provider] = signature
|
|
return false
|
|
}
|
|
|
|
@discardableResult
|
|
func applyIcon(for provider: UsageProvider, phase: Double?) -> Bool {
|
|
guard let button = self.statusItems[provider]?.button else { return false }
|
|
let snapshot = self.store.snapshot(for: provider)
|
|
// IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the
|
|
// user setting we pass either "percent left" or "percent used".
|
|
let showUsed = self.settings.usageBarsShowUsed
|
|
let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent
|
|
let style: IconStyle = self.store.style(for: provider)
|
|
let warningFlash = self.quotaWarningFlashActive(provider: provider)
|
|
|
|
if showBrandPercent,
|
|
let brand = ProviderBrandIcon.image(for: provider)
|
|
{
|
|
let displayText = self.menuBarDisplayText(for: provider, snapshot: snapshot)
|
|
let signature = [
|
|
"mode=brandPercent",
|
|
"provider=\(provider.rawValue)",
|
|
"style=\(String(describing: style))",
|
|
"text=\(displayText ?? "nil")",
|
|
"warningFlash=\(warningFlash ? "1" : "0")",
|
|
].joined(separator: "|")
|
|
if self.shouldSkipProviderIconRender(provider: provider, signature: signature) {
|
|
self.noteIconPerfRender(skipped: true)
|
|
return true
|
|
}
|
|
self.setButtonImage(
|
|
warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button)
|
|
self.setButtonTitle(displayText, for: button)
|
|
self.noteIconPerfRender(skipped: false)
|
|
return false
|
|
}
|
|
|
|
self.setButtonTitle(nil, for: button)
|
|
|
|
// OpenRouter always gets a meter here — the brand-logo fallback was removed on purpose.
|
|
let resolved = self.resolvedMenuBarIconPercents(
|
|
provider: provider,
|
|
snapshot: snapshot,
|
|
style: style,
|
|
showUsed: showUsed)
|
|
var primary = resolved?.primary
|
|
var weekly = resolved?.secondary
|
|
var credits = self.menuBarCreditsRemainingForIcon(provider: provider, snapshot: snapshot)
|
|
var stale = self.store.isStale(provider: provider)
|
|
var morphProgress: Double?
|
|
|
|
if let phase, self.shouldAnimate(provider: provider) {
|
|
var pattern = self.animationPattern
|
|
if provider == .claude, pattern == .unbraid {
|
|
pattern = .cylon
|
|
}
|
|
if pattern == .unbraid {
|
|
morphProgress = pattern.value(phase: phase) / 100
|
|
primary = nil
|
|
weekly = nil
|
|
credits = nil
|
|
stale = false
|
|
} else {
|
|
// Keep loading animation layout stable: IconRenderer switches layouts at `weeklyRemaining == 0`.
|
|
primary = max(pattern.value(phase: phase), Self.loadingPercentEpsilon)
|
|
weekly = max(
|
|
pattern.value(phase: phase + pattern.secondaryOffset),
|
|
Self.loadingPercentEpsilon)
|
|
credits = nil
|
|
stale = false
|
|
}
|
|
}
|
|
|
|
let isLoading = phase != nil && self.shouldAnimate(provider: provider)
|
|
let blink: CGFloat = {
|
|
guard isLoading, style == .warp, let phase else {
|
|
return self.blinkAmount(for: provider)
|
|
}
|
|
let normalized = (sin(phase * 3) + 1) / 2
|
|
return CGFloat(max(0, min(normalized, 1)))
|
|
}()
|
|
let wiggle = self.wiggleAmount(for: provider)
|
|
let tilt = self.tiltAmount(for: provider) * .pi / 28 // limit to ~6.4°
|
|
let statusIndicator = self.store.statusIndicator(for: provider)
|
|
if let morphProgress {
|
|
let signature = [
|
|
"mode=morph",
|
|
"provider=\(provider.rawValue)",
|
|
"style=\(String(describing: style))",
|
|
"morph=\(Self.iconSignatureValue(morphProgress))",
|
|
"status=\(statusIndicator.rawValue)",
|
|
"warningFlash=\(warningFlash ? "1" : "0")",
|
|
"loading=\(isLoading ? "1" : "0")",
|
|
"hideCritters=\(self.settings.menuBarHidesCritters ? "1" : "0")",
|
|
].joined(separator: "|")
|
|
if self.shouldSkipProviderIconRender(provider: provider, signature: signature) {
|
|
self.noteIconPerfRender(skipped: true)
|
|
return true
|
|
}
|
|
let image = IconRenderer.makeMorphIcon(
|
|
progress: morphProgress,
|
|
style: style,
|
|
hideCritters: self.settings.menuBarHidesCritters)
|
|
self.setButtonImage(
|
|
warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button)
|
|
} else {
|
|
let signature = [
|
|
"mode=icon",
|
|
"provider=\(provider.rawValue)",
|
|
"style=\(String(describing: style))",
|
|
"primary=\(Self.iconSignatureValue(primary))",
|
|
"weekly=\(Self.iconSignatureValue(weekly))",
|
|
"credits=\(Self.iconSignatureValue(credits))",
|
|
"stale=\(stale ? "1" : "0")",
|
|
"status=\(statusIndicator.rawValue)",
|
|
"blink=\(Self.iconSignatureValue(Double(blink)))",
|
|
"wiggle=\(Self.iconSignatureValue(Double(wiggle)))",
|
|
"tilt=\(Self.iconSignatureValue(Double(tilt)))",
|
|
"warningFlash=\(warningFlash ? "1" : "0")",
|
|
"loading=\(isLoading ? "1" : "0")",
|
|
"hideCritters=\(self.settings.menuBarHidesCritters ? "1" : "0")",
|
|
].joined(separator: "|")
|
|
if self.shouldSkipProviderIconRender(provider: provider, signature: signature) {
|
|
self.noteIconPerfRender(skipped: true)
|
|
return true
|
|
}
|
|
let image = IconRenderer.makeIcon(
|
|
primaryRemaining: primary,
|
|
weeklyRemaining: weekly,
|
|
creditsRemaining: credits,
|
|
stale: stale,
|
|
style: style,
|
|
blink: blink,
|
|
wiggle: wiggle,
|
|
tilt: tilt,
|
|
statusIndicator: statusIndicator,
|
|
hideCritters: self.settings.menuBarHidesCritters)
|
|
self.setButtonImage(
|
|
warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button)
|
|
}
|
|
self.noteIconPerfRender(skipped: false)
|
|
return false
|
|
}
|
|
|
|
static func iconSignatureValue(_ value: Double?) -> String {
|
|
guard let value else { return "nil" }
|
|
return String(format: "%.3f", value)
|
|
}
|
|
|
|
func resolvedMenuBarIconPercents(
|
|
provider: UsageProvider,
|
|
snapshot: UsageSnapshot?,
|
|
style: IconStyle,
|
|
showUsed: Bool,
|
|
renderingStyle: IconStyle? = nil)
|
|
-> (primary: Double?, secondary: Double?)?
|
|
{
|
|
guard let snapshot else { return nil }
|
|
let preference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot)
|
|
if preference == .monthlyPlan {
|
|
guard let metricWindow = self.menuBarMetricWindowForIconOverride(
|
|
preference: preference,
|
|
provider: provider,
|
|
snapshot: snapshot)
|
|
else {
|
|
return (primary: nil, secondary: nil)
|
|
}
|
|
return (
|
|
primary: showUsed ? metricWindow.usedPercent : metricWindow.remainingPercent,
|
|
secondary: nil)
|
|
}
|
|
if provider == .mistral {
|
|
return (primary: nil, secondary: nil)
|
|
}
|
|
return IconRemainingResolver.resolvedPercents(
|
|
snapshot: snapshot,
|
|
style: style,
|
|
showUsed: showUsed,
|
|
renderingStyle: renderingStyle,
|
|
secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: snapshot))
|
|
}
|
|
|
|
private func menuBarMetricWindowForIconOverride(
|
|
preference: MenuBarMetricPreference,
|
|
provider: UsageProvider,
|
|
snapshot: UsageSnapshot)
|
|
-> RateWindow?
|
|
{
|
|
MenuBarMetricWindowResolver.rateWindow(
|
|
preference: preference,
|
|
provider: provider,
|
|
snapshot: snapshot,
|
|
supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider))
|
|
}
|
|
|
|
func menuBarCreditsRemainingForIcon(
|
|
provider: UsageProvider,
|
|
snapshot: UsageSnapshot?,
|
|
now: Date = Date()) -> Double?
|
|
{
|
|
// Derive the menu-bar credits fallback from the same Codex projection path the rendered
|
|
// icon and menu use (`codexConsumerProjection` -> `menuBarFallback`), instead of a
|
|
// hand-rolled rate-window predicate. The projection is pure value composition over
|
|
// already-loaded snapshot/credits state (no IO), so this stays cheap while keeping the
|
|
// icon render, this signature input, and the menu-bar fallback semantics on a single
|
|
// source of truth — a hand-rolled approximation can silently drift from the projection
|
|
// as its fallback logic evolves.
|
|
guard provider == .codex else { return nil }
|
|
return self.store.codexMenuBarCreditsRemaining(
|
|
snapshotOverride: snapshot,
|
|
now: now)
|
|
}
|
|
|
|
func quotaWarningFlashActive(provider: UsageProvider, now: Date = Date()) -> Bool {
|
|
guard let until = self.quotaWarningFlashUntil[provider] else { return false }
|
|
if until > now { return true }
|
|
self.quotaWarningFlashUntil.removeValue(forKey: provider)
|
|
self.quotaWarningFlashTasks[provider]?.cancel()
|
|
self.quotaWarningFlashTasks.removeValue(forKey: provider)
|
|
return false
|
|
}
|
|
|
|
func startQuotaWarningFlash(provider: UsageProvider, postedAt: Date = Date()) {
|
|
let until = postedAt.addingTimeInterval(Self.quotaWarningFlashDuration)
|
|
self.quotaWarningFlashUntil[provider] = until
|
|
self.quotaWarningFlashTasks[provider]?.cancel()
|
|
self.updateIcons()
|
|
self.applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded()
|
|
self.quotaWarningFlashTasks[provider] = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(Self.quotaWarningFlashDuration))
|
|
await MainActor.run { [weak self] in
|
|
self?.clearExpiredQuotaWarningFlash(provider: provider)
|
|
}
|
|
}
|
|
}
|
|
|
|
func clearExpiredQuotaWarningFlash(provider: UsageProvider, now: Date = Date()) {
|
|
guard let currentUntil = self.quotaWarningFlashUntil[provider],
|
|
currentUntil <= now
|
|
else {
|
|
return
|
|
}
|
|
self.quotaWarningFlashUntil.removeValue(forKey: provider)
|
|
self.quotaWarningFlashTasks.removeValue(forKey: provider)
|
|
self.updateIcons()
|
|
self.applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded()
|
|
}
|
|
|
|
private func applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() {
|
|
guard self.shouldMergeIcons,
|
|
self.isMergedMenuOpen
|
|
else {
|
|
return
|
|
}
|
|
let phase: Double? = self.animationDriver == nil ? nil : self.animationPhase
|
|
self.applyIcon(phase: phase, bypassMergedMenuTrackingDeferral: true)
|
|
}
|
|
|
|
static func quotaWarningFlashImage(base: NSImage) -> NSImage {
|
|
let image = NSImage(size: base.size)
|
|
image.lockFocus()
|
|
let rect = NSRect(origin: .zero, size: base.size)
|
|
NSColor.systemRed.withAlphaComponent(0.22).setFill()
|
|
NSBezierPath(roundedRect: rect.insetBy(dx: 1, dy: 1), xRadius: 4, yRadius: 4).fill()
|
|
base.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
|
|
NSColor.systemRed.withAlphaComponent(0.28).setFill()
|
|
NSBezierPath(rect: rect).fill()
|
|
image.unlockFocus()
|
|
image.isTemplate = false
|
|
return image
|
|
}
|
|
|
|
private func setButtonImage(_ image: NSImage, for button: NSStatusBarButton) {
|
|
if button.image === image { return }
|
|
button.image = image
|
|
}
|
|
|
|
private func setButtonTitle(_ title: String?, for button: NSStatusBarButton) {
|
|
let isDebugApp = Self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier)
|
|
let value = Self.buttonTitle(
|
|
title,
|
|
hasImage: button.image != nil || title == nil,
|
|
isDebugApp: isDebugApp)
|
|
if button.title != value {
|
|
button.title = value
|
|
}
|
|
let position: NSControl.ImagePosition = value.isEmpty ? .imageOnly : .imageLeft
|
|
if button.imagePosition != position {
|
|
button.imagePosition = position
|
|
}
|
|
}
|
|
|
|
nonisolated static func buttonTitle(_ title: String?, hasImage: Bool, isDebugApp: Bool = false) -> String {
|
|
var parts: [String] = []
|
|
if let title, !title.isEmpty {
|
|
parts.append(title)
|
|
}
|
|
if isDebugApp {
|
|
parts.append("D")
|
|
}
|
|
let value = parts.joined(separator: " ")
|
|
return hasImage && !value.isEmpty ? " \(value)" : value
|
|
}
|
|
|
|
func menuBarDisplayText(
|
|
for provider: UsageProvider,
|
|
snapshot: UsageSnapshot?,
|
|
now: Date = .init()) -> String?
|
|
{
|
|
let mode = self.settings.menuBarDisplayMode
|
|
if provider == .openrouter,
|
|
self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .automatic,
|
|
let balance = snapshot?.openRouterUsage?.balance
|
|
{
|
|
return UsageFormatter.usdString(balance)
|
|
}
|
|
if provider == .crossmodel,
|
|
self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .automatic,
|
|
let usage = snapshot?.crossModelUsage
|
|
{
|
|
return usage.balanceDisplay
|
|
}
|
|
if provider == .opencodego,
|
|
let balance = Self.openCodeGoZenBalanceDisplayText(snapshot: snapshot)
|
|
{
|
|
return balance
|
|
}
|
|
if provider == .deepseek,
|
|
let balance = Self.deepSeekBalanceDisplayText(snapshot: snapshot)
|
|
{
|
|
return balance
|
|
}
|
|
if provider == .mimo,
|
|
let balance = Self.miMoBalanceDisplayText(
|
|
snapshot: snapshot,
|
|
preference: self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot))
|
|
{
|
|
return balance
|
|
}
|
|
if provider == .moonshot,
|
|
let balance = Self.moonshotBalanceDisplayText(snapshot: snapshot)
|
|
{
|
|
return balance
|
|
}
|
|
if provider == .poe,
|
|
let balance = Self.poeBalanceDisplayText(snapshot: snapshot)
|
|
{
|
|
return balance
|
|
}
|
|
if provider == .mistral {
|
|
let preference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot)
|
|
let hasMonthlyPlan = snapshot?.extraRateWindows?.contains { $0.id == "mistral-monthly-plan" } == true
|
|
if preference != .monthlyPlan || !hasMonthlyPlan,
|
|
let spend = Self.mistralSpendDisplayText(snapshot: snapshot)
|
|
{
|
|
return spend
|
|
}
|
|
}
|
|
if provider == .kimik2,
|
|
let credits = Self.kimiK2CreditsDisplayText(snapshot: snapshot)
|
|
{
|
|
return credits
|
|
}
|
|
if provider == .kiro {
|
|
return Self.kiroDisplayText(
|
|
snapshot: snapshot,
|
|
mode: self.settings.kiroMenuBarDisplayMode,
|
|
showUsed: self.settings.usageBarsShowUsed)
|
|
}
|
|
if mode != .resetTime,
|
|
self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .extraUsage,
|
|
provider != .cursor || mode == .pace,
|
|
let spend = Self.extraUsageSpendDisplayText(snapshot: snapshot)
|
|
{
|
|
return spend
|
|
}
|
|
|
|
let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot, now: now)
|
|
let codexProjection = self.store.codexConsumerProjectionIfNeeded(
|
|
for: provider,
|
|
surface: .menuBar,
|
|
snapshotOverride: snapshot,
|
|
now: now)
|
|
|
|
// The combined "Session + Weekly" metric (Codex and Claude) shows both lanes in percent mode
|
|
// ("5h 12% · W 45%") and, in pace/both modes, pairs the session usage with the weekly pace.
|
|
let combinedLanes = self.combinedSessionWeeklyLanes(
|
|
for: provider, snapshot: snapshot, projection: codexProjection)
|
|
|
|
let pace: UsagePace?
|
|
switch mode {
|
|
case .percent:
|
|
pace = nil
|
|
case .pace, .both:
|
|
let paceWindow = self.menuBarPaceWindow(
|
|
for: provider,
|
|
snapshot: snapshot,
|
|
projection: codexProjection,
|
|
combinedLanes: combinedLanes,
|
|
percentWindow: percentWindow)
|
|
pace = paceWindow.flatMap { window in
|
|
self.store.weeklyPace(provider: provider, window: window, now: now)
|
|
}
|
|
case .resetTime:
|
|
return MenuBarDisplayText.displayText(
|
|
mode: mode,
|
|
percentWindow: percentWindow,
|
|
showUsed: self.settings.usageBarsShowUsed,
|
|
resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle,
|
|
now: now)
|
|
}
|
|
if mode == .percent,
|
|
!self.settings.usageBarsShowUsed,
|
|
codexProjection?.menuBarFallback == .creditsBalance,
|
|
let creditsRemaining = codexProjection?.credits?.remaining,
|
|
creditsRemaining > 0
|
|
{
|
|
return
|
|
UsageFormatter
|
|
.creditsString(from: creditsRemaining)
|
|
.replacingOccurrences(of: " left", with: "")
|
|
}
|
|
if let combinedLanes, mode == .percent {
|
|
if let combinedText = MenuBarDisplayText.combinedSessionWeeklyPercentText(
|
|
sessionWindow: combinedLanes.session,
|
|
weeklyWindow: combinedLanes.weekly,
|
|
showUsed: self.settings.usageBarsShowUsed,
|
|
resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle,
|
|
showsResetTimeWhenExhausted: self.settings.menuBarShowsResetTimeWhenExhausted,
|
|
now: now)
|
|
{
|
|
return combinedText
|
|
}
|
|
}
|
|
|
|
let displayPercentWindow: RateWindow? = if let combinedLanes {
|
|
Self.combinedDisplayPercentWindow(lanes: combinedLanes, fallback: percentWindow)
|
|
} else {
|
|
percentWindow
|
|
}
|
|
return MenuBarDisplayText.displayText(
|
|
mode: mode,
|
|
percentWindow: displayPercentWindow,
|
|
pace: pace,
|
|
showUsed: self.settings.usageBarsShowUsed,
|
|
resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle,
|
|
showsResetTimeWhenExhausted: self.settings.menuBarShowsResetTimeWhenExhausted,
|
|
now: now)
|
|
}
|
|
|
|
nonisolated static func deepSeekBalanceDisplayText(snapshot: UsageSnapshot?) -> String? {
|
|
guard
|
|
let rawValue = snapshot?.primary?.resetDescription?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!rawValue.isEmpty,
|
|
rawValue.hasPrefix("$") || rawValue.hasPrefix("¥")
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let balance = rawValue.split(separator: " ", maxSplits: 1).first
|
|
return balance.map(String.init)
|
|
}
|
|
|
|
nonisolated static func miMoBalanceDisplayText(
|
|
snapshot: UsageSnapshot?,
|
|
preference: MenuBarMetricPreference) -> String?
|
|
{
|
|
guard let snapshot, let mimoUsage = snapshot.mimoUsage else { return nil }
|
|
if snapshot.primary != nil, preference != .secondary { return nil }
|
|
let detail = mimoUsage.balanceDetail
|
|
return detail.components(separatedBy: " (Paid:").first
|
|
}
|
|
|
|
nonisolated static func poeBalanceDisplayText(snapshot: UsageSnapshot?) -> String? {
|
|
self.displayValue(
|
|
from: snapshot?.loginMethod(for: .poe),
|
|
prefix: "Balance:",
|
|
removingSuffix: "")
|
|
}
|
|
|
|
nonisolated static func moonshotBalanceDisplayText(snapshot: UsageSnapshot?) -> String? {
|
|
self.displayValue(
|
|
from: snapshot?.loginMethod(for: .moonshot),
|
|
prefix: "Balance:",
|
|
removingSuffix: "")
|
|
.flatMap { value in
|
|
value
|
|
.split(separator: "·", maxSplits: 1)
|
|
.first?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|
|
|
|
nonisolated static func mistralSpendDisplayText(snapshot: UsageSnapshot?) -> String? {
|
|
self.displayValue(
|
|
from: snapshot?.identity?.loginMethod,
|
|
prefix: "API spend:",
|
|
removingSuffix: " this month")
|
|
}
|
|
|
|
nonisolated static func kimiK2CreditsDisplayText(snapshot: UsageSnapshot?) -> String? {
|
|
self.displayValue(
|
|
from: snapshot?.identity?.loginMethod,
|
|
prefix: "Credits:",
|
|
removingSuffix: " left")
|
|
}
|
|
|
|
nonisolated static func extraUsageSpendDisplayText(snapshot: UsageSnapshot?) -> String? {
|
|
guard let cost = snapshot?.providerCost,
|
|
cost.limit > 0,
|
|
cost.used >= 0
|
|
else {
|
|
return nil
|
|
}
|
|
return UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
|
|
}
|
|
|
|
nonisolated static func openCodeGoZenBalanceDisplayText(snapshot: UsageSnapshot?) -> String? {
|
|
guard snapshot?.primary == nil,
|
|
snapshot?.secondary == nil,
|
|
let cost = snapshot?.providerCost,
|
|
cost.period == "Zen balance"
|
|
else {
|
|
return nil
|
|
}
|
|
return UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
|
|
}
|
|
|
|
nonisolated static func kiroDisplayText(
|
|
snapshot: UsageSnapshot?,
|
|
mode: KiroMenuBarDisplayMode,
|
|
showUsed: Bool)
|
|
-> String?
|
|
{
|
|
guard mode != .hidden else { return nil }
|
|
guard let usage = snapshot?.kiroUsage else {
|
|
return MenuBarDisplayText.percentText(window: snapshot?.primary, showUsed: showUsed)
|
|
}
|
|
let percentText = MenuBarDisplayText.percentText(
|
|
window: snapshot?.primary,
|
|
showUsed: showUsed)
|
|
let creditsLeft = UsageFormatter.kiroCreditNumber(usage.creditsRemaining)
|
|
let usedTotal = [
|
|
UsageFormatter.kiroCreditNumber(usage.creditsUsed),
|
|
UsageFormatter.kiroCreditNumber(usage.creditsTotal),
|
|
].joined(separator: " / ")
|
|
|
|
switch mode {
|
|
case .automatic, .creditsLeft:
|
|
if usage.creditsTotal > 0 {
|
|
return creditsLeft
|
|
}
|
|
return percentText
|
|
case .hidden:
|
|
return nil
|
|
case .percentLeft:
|
|
return MenuBarDisplayText.percentText(window: snapshot?.primary, showUsed: false)
|
|
case .creditsAndPercent:
|
|
guard usage.creditsTotal > 0 else { return percentText }
|
|
guard let percentText else { return creditsLeft }
|
|
return "\(creditsLeft) · \(percentText)"
|
|
case .usedAndTotal:
|
|
guard usage.creditsTotal > 0 else { return percentText }
|
|
return usedTotal
|
|
case .overageCreditsWhenExhausted:
|
|
return self.kiroOverageDisplayText(
|
|
usage: usage,
|
|
format: .credits,
|
|
fallback: creditsLeft,
|
|
percentFallback: percentText)
|
|
case .overageCostWhenExhausted:
|
|
return self.kiroOverageDisplayText(
|
|
usage: usage,
|
|
format: .cost,
|
|
fallback: creditsLeft,
|
|
percentFallback: percentText)
|
|
case .overageCreditsAndCostWhenExhausted:
|
|
return self.kiroOverageDisplayText(
|
|
usage: usage,
|
|
format: .creditsAndCost,
|
|
fallback: creditsLeft,
|
|
percentFallback: percentText)
|
|
}
|
|
}
|
|
|
|
private enum KiroOverageDisplayFormat {
|
|
case credits
|
|
case cost
|
|
case creditsAndCost
|
|
}
|
|
|
|
private nonisolated static func kiroOverageDisplayText(
|
|
usage: KiroUsageDetails,
|
|
format: KiroOverageDisplayFormat,
|
|
fallback: String,
|
|
percentFallback: String?)
|
|
-> String?
|
|
{
|
|
guard usage.creditsTotal > 0 else { return percentFallback }
|
|
guard usage.creditsRemaining <= 0 else { return fallback }
|
|
guard
|
|
usage.overagesStatus?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
.hasPrefix("enabled") == true
|
|
else {
|
|
return fallback
|
|
}
|
|
|
|
let credits = usage.overageCreditsUsed.map { "\(UsageFormatter.kiroCreditNumber($0)) over" }
|
|
let cost = usage.estimatedOverageCostUSD.map { "\(UsageFormatter.usdString($0)) over" }
|
|
|
|
switch format {
|
|
case .credits:
|
|
return credits ?? cost ?? fallback
|
|
case .cost:
|
|
return cost ?? credits ?? fallback
|
|
case .creditsAndCost:
|
|
if let credits, let cost {
|
|
let creditsValue = credits.replacingOccurrences(of: " over", with: "")
|
|
let costValue = cost.replacingOccurrences(of: " over", with: "")
|
|
return "\(creditsValue) · \(costValue)"
|
|
}
|
|
return credits ?? cost ?? fallback
|
|
}
|
|
}
|
|
|
|
private nonisolated static func displayValue(
|
|
from text: String?,
|
|
prefix: String,
|
|
removingSuffix suffix: String)
|
|
-> String?
|
|
{
|
|
guard let rawValue = text?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
rawValue.hasPrefix(prefix)
|
|
else {
|
|
return nil
|
|
}
|
|
let valueStart = rawValue.index(rawValue.startIndex, offsetBy: prefix.count)
|
|
var value = rawValue[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !suffix.isEmpty, value.hasSuffix(suffix) {
|
|
value = String(value.dropLast(suffix.count)).trimmingCharacters(
|
|
in: .whitespacesAndNewlines)
|
|
}
|
|
return value.isEmpty ? nil : value
|
|
}
|
|
|
|
private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?, now: Date)
|
|
-> RateWindow?
|
|
{
|
|
self.menuBarMetricWindow(for: provider, snapshot: snapshot, now: now)
|
|
}
|
|
|
|
/// Resolves the session (5h) and weekly (7d) lanes for the combined "Session + Weekly" menu-bar
|
|
/// metric, or nil when that metric is not active for `provider`. Codex resolves its lanes through the
|
|
/// consumer projection; Claude has none, so it classifies by window cadence — a 7-day window the OAuth
|
|
/// mapper parked in `primary` (the five_hour fallback) must not be mislabeled as a 5-hour session lane.
|
|
private func combinedSessionWeeklyLanes(
|
|
for provider: UsageProvider,
|
|
snapshot: UsageSnapshot?,
|
|
projection: CodexConsumerProjection?) -> (session: RateWindow?, weekly: RateWindow?)?
|
|
{
|
|
guard provider == .codex || provider == .claude,
|
|
self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .primaryAndSecondary
|
|
else { return nil }
|
|
// A Claude account that only exposes an enterprise/extra-usage spend limit has no real
|
|
// session/weekly lanes; defer to the resolver's spend-limit routing instead of rendering an
|
|
// empty or 0% placeholder lane under the combined metric.
|
|
if provider == .claude,
|
|
let snapshot,
|
|
MenuBarMetricWindowResolver.claudeSpendLimitWindow(snapshot: snapshot) != nil
|
|
{
|
|
return nil
|
|
}
|
|
let session = Self.combinedSessionLane(snapshot: snapshot, projection: projection)
|
|
let weekly: RateWindow? = if let projection {
|
|
projection.menuBarSelectableRateWindow(for: .weekly)
|
|
} else {
|
|
Self.rateWindow(in: snapshot, matchingCadenceMinutes: Self.weeklyWindowMinutes)
|
|
}
|
|
return (session, weekly)
|
|
}
|
|
|
|
/// Reset dates for every lane whose menu-bar text is currently rendered as a reset time, so the
|
|
/// countdown scheduler can refresh each of them. Reset-time mode drives a single window. The smart
|
|
/// "reset time when exhausted" option can surface BOTH combined session/weekly lanes in percent mode,
|
|
/// while pace/both render the one lane chosen by `combinedDisplayPercentWindow` — mirror that presentation
|
|
/// here rather than scheduling whichever lane happened to drive the icon.
|
|
func menuBarDisplayedResetDates(for provider: UsageProvider, now: Date) -> [Date] {
|
|
let snapshot = self.store.snapshot(for: provider)
|
|
let mode = self.settings.menuBarDisplayMode
|
|
|
|
let projection = self.store.codexConsumerProjectionIfNeeded(
|
|
for: provider,
|
|
surface: .menuBar,
|
|
snapshotOverride: snapshot,
|
|
now: now)
|
|
if let lanes = self.combinedSessionWeeklyLanes(
|
|
for: provider, snapshot: snapshot, projection: projection),
|
|
lanes.session != nil || lanes.weekly != nil
|
|
{
|
|
switch mode {
|
|
case .percent:
|
|
// Percent renders both lanes independently, so schedule every exhausted reset.
|
|
return [lanes.session, lanes.weekly]
|
|
.compactMap(\.self)
|
|
.filter { $0.remainingPercent <= 0 }
|
|
.compactMap(\.resetsAt)
|
|
case .pace, .both:
|
|
// Pace/both render one usage lane alongside the weekly pace. Use that exact lane rather
|
|
// than `menuBarMetricWindow`, whose tie-breaking can select the other exhausted window.
|
|
let window = Self.combinedDisplayPercentWindow(
|
|
lanes: lanes,
|
|
fallback: self.menuBarMetricWindow(for: provider, snapshot: snapshot, now: now))
|
|
guard let window, window.remainingPercent <= 0 else { return [] }
|
|
return window.resetsAt.map { [$0] } ?? []
|
|
case .resetTime:
|
|
break
|
|
}
|
|
}
|
|
|
|
guard let window = self.menuBarMetricWindow(for: provider, snapshot: snapshot, now: now)
|
|
else { return [] }
|
|
// Outside reset-time mode the reset text is only visible once the quota is exhausted.
|
|
if mode != .resetTime, window.remainingPercent > 0 { return [] }
|
|
return window.resetsAt.map { [$0] } ?? []
|
|
}
|
|
|
|
/// The combined metric's session (5h) lane. Codex resolves it through the consumer projection; other
|
|
/// providers classify by window cadence. A 5-hour lane the provider only synthesized to stand in for an
|
|
/// absent session — Claude web's null `five_hour` placeholder, flagged at the boundary — is dropped so a
|
|
/// weekly-only account falls back to its weekly lane instead of rendering a phantom `5h 0%`/`5h 100%`
|
|
/// session. A genuine session (even one freshly reset to 0%) is not flagged, so it is kept.
|
|
private static func combinedSessionLane(
|
|
snapshot: UsageSnapshot?,
|
|
projection: CodexConsumerProjection?) -> RateWindow?
|
|
{
|
|
if let projection {
|
|
return projection.menuBarSelectableRateWindow(for: .session)
|
|
}
|
|
guard let session = Self.rateWindow(in: snapshot, matchingCadenceMinutes: Self.sessionWindowMinutes)
|
|
else { return nil }
|
|
if session.isSyntheticPlaceholder {
|
|
return nil
|
|
}
|
|
return session
|
|
}
|
|
|
|
/// The window the weekly pace is computed on in pace/both modes. Codex paces on its projected weekly
|
|
/// lane; the combined Session + Weekly metric paces on the weekly lane too (matching Codex); Abacus
|
|
/// has no secondary window so it paces on the primary monthly credits; everything else paces on the
|
|
/// selected percent window.
|
|
private func menuBarPaceWindow(
|
|
for provider: UsageProvider,
|
|
snapshot: UsageSnapshot?,
|
|
projection: CodexConsumerProjection?,
|
|
combinedLanes: (session: RateWindow?, weekly: RateWindow?)?,
|
|
percentWindow: RateWindow?) -> RateWindow?
|
|
{
|
|
if let projection {
|
|
return projection.menuBarSelectableRateWindow(for: .weekly)
|
|
}
|
|
if provider == .abacus {
|
|
return snapshot?.primary
|
|
}
|
|
if let combinedLanes {
|
|
return combinedLanes.weekly
|
|
}
|
|
return percentWindow
|
|
}
|
|
|
|
/// The usage window shown for the combined metric in pace/both modes. It pairs the SESSION usage with
|
|
/// the weekly pace, so the usage component normally comes from the session lane — not the
|
|
/// most-constrained lane that drives the icon/bar. Two exceptions: fall back to the weekly lane when no
|
|
/// session lane exists (the five_hour OAuth fallback or Claude web's filtered null-session
|
|
/// placeholder), and surface the weekly lane when it is exhausted
|
|
/// — it is then the binding cap with no pace to show, and a roomy session number would hide it.
|
|
private static func combinedDisplayPercentWindow(
|
|
lanes: (session: RateWindow?, weekly: RateWindow?),
|
|
fallback: RateWindow?) -> RateWindow?
|
|
{
|
|
if let weekly = lanes.weekly, weekly.remainingPercent <= 0 {
|
|
return weekly
|
|
}
|
|
return lanes.session ?? lanes.weekly ?? fallback
|
|
}
|
|
|
|
private static let sessionWindowMinutes = 5 * 60
|
|
private static let weeklyWindowMinutes = 7 * 24 * 60
|
|
|
|
/// Returns the first session/weekly snapshot lane whose window cadence matches `minutes`.
|
|
/// Used by the combined Session + Weekly metric for providers without a Codex consumer
|
|
/// projection so a fallback weekly window parked in `primary` is not mislabeled as a session lane.
|
|
private static func rateWindow(in snapshot: UsageSnapshot?, matchingCadenceMinutes minutes: Int) -> RateWindow? {
|
|
[snapshot?.primary, snapshot?.secondary]
|
|
.compactMap(\.self)
|
|
.first { $0.windowMinutes == minutes }
|
|
}
|
|
|
|
func primaryProviderForUnifiedIcon() -> UsageProvider {
|
|
// When "show highest usage" is enabled, rank the existing Overview subset by proximity to its limit.
|
|
if self.settings.menuBarShowsHighestUsage, self.shouldMergeIcons {
|
|
let activeProviders = self.store.enabledProvidersForDisplay()
|
|
let overviewProviders = self.settings.resolvedMergedOverviewProviders(
|
|
activeProviders: activeProviders,
|
|
maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit)
|
|
if let highest = self.store.providerWithHighestUsage(candidateProviders: overviewProviders) {
|
|
return highest.provider
|
|
}
|
|
// A nonempty Overview selection remains authoritative while its providers are loading,
|
|
// unrankable, or exhausted. Only an explicitly empty Overview may use the broad fallback.
|
|
if let fallback = overviewProviders.first(where: { self.store.isEnabled($0) }) {
|
|
return fallback
|
|
}
|
|
}
|
|
if self.shouldMergeIcons, self.settings.mergedMenuLastSelectedWasOverview {
|
|
let enabledProviders = self.store.enabledProvidersForDisplay()
|
|
let overviewProviders = self.settings.resolvedMergedOverviewProviders(
|
|
activeProviders: enabledProviders,
|
|
maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit)
|
|
if let provider = overviewProviders.first(where: { self.store.isEnabled($0) }) {
|
|
return provider
|
|
}
|
|
}
|
|
if self.shouldMergeIcons,
|
|
let selected = self.selectedMenuProvider,
|
|
self.store.isEnabled(selected)
|
|
{
|
|
return selected
|
|
}
|
|
for provider in self.store.enabledProviders() {
|
|
if self.store.isEnabled(provider), self.store.snapshot(for: provider) != nil {
|
|
return provider
|
|
}
|
|
}
|
|
// Use availability-filtered list: fallback must pick a provider that can
|
|
// actually animate, otherwise shouldAnimate() fails on credential-less providers.
|
|
if let enabled = self.store.enabledProviders().first {
|
|
return enabled
|
|
}
|
|
return .codex
|
|
}
|
|
|
|
@objc func handleDebugBlinkNotification() {
|
|
#if DEBUG
|
|
guard !self.isReleasedForTesting else { return }
|
|
#endif
|
|
self.forceBlinkNow()
|
|
}
|
|
|
|
private func forceBlinkNow() {
|
|
let now = Date()
|
|
self.blinkForceUntil = now.addingTimeInterval(0.6)
|
|
self.seedBlinkStatesIfNeeded()
|
|
|
|
for provider in UsageProvider.allCases {
|
|
let shouldBlink =
|
|
self.shouldMergeIcons ? self.isEnabled(provider) : self.isVisible(provider)
|
|
guard shouldBlink, !self.shouldAnimate(provider: provider) else { continue }
|
|
var state =
|
|
self
|
|
.blinkStates[provider]
|
|
?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay()))
|
|
state.blinkStart = now
|
|
state.pendingSecondStart = nil
|
|
state.effect = self.randomEffect(for: provider)
|
|
state.nextBlink = now.addingTimeInterval(BlinkState.randomDelay())
|
|
self.blinkStates[provider] = state
|
|
self.assignMotion(amount: 0, for: provider, effect: state.effect)
|
|
}
|
|
|
|
// If the blink task is currently in a long idle sleep, restart it so this forced blink
|
|
// keeps animating on the active frame cadence immediately.
|
|
self.blinkTask?.cancel()
|
|
self.blinkTask = nil
|
|
self.updateBlinkingState()
|
|
self.tickBlink(now: now)
|
|
}
|
|
|
|
func shouldAnimate(provider: UsageProvider, mergeIcons: Bool? = nil) -> Bool {
|
|
if self.store.debugForceAnimation { return true }
|
|
|
|
let isMerged = mergeIcons ?? self.shouldMergeIcons
|
|
let isVisible = isMerged ? self.isEnabled(provider) : self.isVisible(provider)
|
|
guard isVisible else { return false }
|
|
|
|
// Don't animate for fallback provider - it's only shown as a placeholder when nothing is enabled.
|
|
// Animating the fallback causes unnecessary CPU usage (battery drain). See #269, #139.
|
|
let isEnabled = self.isEnabled(provider)
|
|
let isFallbackOnly = !isEnabled && self.fallbackProvider == provider
|
|
if isFallbackOnly { return false }
|
|
|
|
let isStale = self.store.isStale(provider: provider)
|
|
let hasSatisfiedUsageFetch = self.store.hasSatisfiedUsageFetch(for: provider)
|
|
if provider == .warp, !hasSatisfiedUsageFetch, self.store.refreshingProviders.contains(provider) {
|
|
return true
|
|
}
|
|
return !hasSatisfiedUsageFetch && !isStale
|
|
}
|
|
|
|
func updateAnimationState() {
|
|
let needsAnimation = self.needsMenuBarIconAnimation()
|
|
if needsAnimation {
|
|
if self.animationDriver == nil {
|
|
if let forced = self.settings.debugLoadingPattern {
|
|
self.animationPattern = forced
|
|
} else if !LoadingPattern.allCases.contains(self.animationPattern) {
|
|
self.animationPattern = .knightRider
|
|
}
|
|
self.animationPhase = 0
|
|
self.animationStartedAt = Date()
|
|
let driver = DisplayLinkDriver(onTick: { [weak self] in
|
|
self?.updateAnimationFrame()
|
|
})
|
|
self.animationDriver = driver
|
|
driver.start(fps: Self.loadingAnimationFPS)
|
|
} else if let forced = self.settings.debugLoadingPattern,
|
|
forced != self.animationPattern
|
|
{
|
|
self.animationPattern = forced
|
|
self.animationPhase = 0
|
|
}
|
|
} else {
|
|
self.stopLoadingAnimation()
|
|
}
|
|
}
|
|
|
|
private func stopLoadingAnimation() {
|
|
self.animationDriver?.stop()
|
|
self.animationDriver = nil
|
|
self.animationPhase = 0
|
|
self.animationStartedAt = nil
|
|
if self.shouldMergeIcons {
|
|
self.applyIcon(phase: nil)
|
|
} else {
|
|
UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: nil) }
|
|
}
|
|
}
|
|
|
|
private func updateAnimationFrame() {
|
|
#if DEBUG
|
|
guard !self.isReleasedForTesting else { return }
|
|
#endif
|
|
if let startedAt = self.animationStartedAt,
|
|
Date().timeIntervalSince(startedAt) > Self.loadingAnimationMaxContinuousDuration
|
|
{
|
|
self.stopLoadingAnimation()
|
|
return
|
|
}
|
|
self.animationPhase += Self.loadingAnimationPhaseIncrement
|
|
if self.shouldMergeIcons {
|
|
self.applyIcon(phase: self.animationPhase)
|
|
} else {
|
|
UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: self.animationPhase) }
|
|
}
|
|
}
|
|
|
|
nonisolated static func brandImageWithStatusOverlay(
|
|
brand: NSImage,
|
|
statusIndicator: ProviderStatusIndicator) -> NSImage
|
|
{
|
|
guard statusIndicator.hasIssue else { return brand }
|
|
|
|
let image = NSImage(size: brand.size)
|
|
image.lockFocus()
|
|
brand.draw(
|
|
at: .zero,
|
|
from: NSRect(origin: .zero, size: brand.size),
|
|
operation: .sourceOver,
|
|
fraction: 1.0)
|
|
Self.drawBrandStatusOverlay(indicator: statusIndicator, size: brand.size)
|
|
image.unlockFocus()
|
|
image.isTemplate = brand.isTemplate
|
|
return image
|
|
}
|
|
|
|
private nonisolated static func drawBrandStatusOverlay(
|
|
indicator: ProviderStatusIndicator, size: NSSize)
|
|
{
|
|
guard indicator.hasIssue else { return }
|
|
|
|
let color = NSColor.labelColor
|
|
switch indicator {
|
|
case .minor, .maintenance:
|
|
let dotSize = CGSize(width: 4, height: 4)
|
|
let dotOrigin = CGPoint(x: size.width - dotSize.width - 2, y: 2)
|
|
color.setFill()
|
|
NSBezierPath(ovalIn: CGRect(origin: dotOrigin, size: dotSize)).fill()
|
|
case .major, .critical, .unknown:
|
|
color.setFill()
|
|
let lineRect = CGRect(x: size.width - 6, y: 4, width: 2, height: 6)
|
|
NSBezierPath(roundedRect: lineRect, xRadius: 1, yRadius: 1).fill()
|
|
let dotRect = CGRect(x: size.width - 6, y: 2, width: 2, height: 2)
|
|
NSBezierPath(ovalIn: dotRect).fill()
|
|
case .none:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func advanceAnimationPattern() {
|
|
let patterns = LoadingPattern.allCases
|
|
if let idx = patterns.firstIndex(of: self.animationPattern) {
|
|
let next = patterns.indices.contains(idx + 1) ? patterns[idx + 1] : patterns.first
|
|
self.animationPattern = next ?? .knightRider
|
|
} else {
|
|
self.animationPattern = .knightRider
|
|
}
|
|
}
|
|
|
|
@objc func handleDebugReplayNotification(_ notification: Notification) {
|
|
#if DEBUG
|
|
guard !self.isReleasedForTesting else { return }
|
|
#endif
|
|
if let raw = notification.userInfo?["pattern"] as? String,
|
|
let selected = LoadingPattern(rawValue: raw)
|
|
{
|
|
self.animationPattern = selected
|
|
} else if let forced = self.settings.debugLoadingPattern {
|
|
self.animationPattern = forced
|
|
} else {
|
|
self.advanceAnimationPattern()
|
|
}
|
|
self.animationPhase = 0
|
|
self.updateAnimationState()
|
|
}
|
|
}
|