import CodexBarCore import Observation struct MenuCardLiveSubtitle { let text: String let style: UsageMenuCardView.Model.SubtitleStyle } /// Updates values in an already-hosted card without rebuilding its tracked NSMenu. @MainActor @Observable final class MenuCardRefreshMonitor { typealias ModelResolver = @MainActor (UsageProvider) -> UsageMenuCardView.Model? typealias ProviderRefreshStateResolver = @MainActor (UsageProvider) -> Bool private let resolveModel: ModelResolver private let isProviderRefreshActive: ProviderRefreshStateResolver /// Set while an all-providers refresh is running; individual cards freeze only while their /// provider has active refresh work. private var globalManualRefreshInFlight = false /// Providers with an individual manual refresh in flight. Concurrent entries are allowed so /// refreshing one provider does not stall or unfreeze another. private var manualRefreshProviders: Set = [] private var frozenManualRefreshModels: [UsageProvider: UsageMenuCardView.Model] = [:] /// True while any manual refresh (global or per-provider) is running. var isManualRefreshInFlight: Bool { self.globalManualRefreshInFlight || !self.manualRefreshProviders.isEmpty } init( resolveModel: @escaping ModelResolver, isProviderRefreshActive: @escaping ProviderRefreshStateResolver) { self.resolveModel = resolveModel self.isProviderRefreshActive = isProviderRefreshActive } func beginManualRefresh( frozenModels: [UsageProvider: UsageMenuCardView.Model], provider: UsageProvider? = nil) { if let provider { self.frozenManualRefreshModels[provider] = frozenModels[provider] self.manualRefreshProviders.insert(provider) } else { self.frozenManualRefreshModels = frozenModels self.globalManualRefreshInFlight = true } } /// Balances a `beginManualRefresh` with the same `provider` argument (nil ends the global refresh). func endManualRefresh(for provider: UsageProvider? = nil) { if let provider { self.manualRefreshProviders.remove(provider) self.frozenManualRefreshModels[provider] = nil } else { self.globalManualRefreshInFlight = false self.frozenManualRefreshModels.removeAll(keepingCapacity: true) } } func resetManualRefresh() { self.globalManualRefreshInFlight = false self.manualRefreshProviders.removeAll(keepingCapacity: true) self.frozenManualRefreshModels.removeAll(keepingCapacity: true) } func isManualRefreshInFlight(for provider: UsageProvider) -> Bool { self.manualRefreshProviders.contains(provider) || (self.globalManualRefreshInFlight && self.isProviderRefreshActive(provider)) } func model( for provider: UsageProvider, fallback: UsageMenuCardView.Model) -> UsageMenuCardView.Model { guard !self.isManualRefreshInFlight(for: provider) else { guard let frozen = self.frozenManualRefreshModels[provider] else { return fallback } if fallback.hasCompatibleTrackedLayout(with: frozen) { return frozen } // A rebuilding menu may temporarily lose some metric rows, but retained rows and other sections // must still match the frozen layout. if fallback.hasCompatibleTrackedMetricSubset(of: frozen) { return frozen } return fallback } guard let resolved = self.resolveModel(provider), fallback.hasCompatibleTrackedLayout(with: resolved) else { return fallback } return resolved } func subtitle( for provider: UsageProvider, fallback: MenuCardLiveSubtitle) -> MenuCardLiveSubtitle { if self.isManualRefreshInFlight(for: provider) { return MenuCardLiveSubtitle(text: "\(L("Refreshing"))…", style: .loading) } guard let model = self.resolveModel(provider) else { return fallback } return MenuCardLiveSubtitle(text: model.subtitleText, style: model.subtitleStyle) } }