Files
steipete--codexbar/Sources/CodexBar/MenuCardRefreshMonitor.swift
T
2026-07-13 12:22:33 +08:00

112 lines
4.2 KiB
Swift

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<UsageProvider> = []
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)
}
}