import SwiftUI public struct UsageGradient { private struct ColorStop { let t: Double let r: Double let g: Double let b: Double } private static let stops: [ColorStop] = [ ColorStop(t: 0.00, r: 16/255.0, g: 185/255.0, b: 129/255.0), ColorStop(t: 0.35, r: 52/255.0, g: 211/255.0, b: 153/255.0), ColorStop(t: 0.50, r: 163/255.0, g: 230/255.0, b: 53/255.0), ColorStop(t: 0.65, r: 245/255.0, g: 158/255.0, b: 11/255.0), ColorStop(t: 0.82, r: 249/255.0, g: 115/255.0, b: 22/255.0), ColorStop(t: 1.00, r: 244/255.0, g: 63/255.0, b: 94/255.0) ] public static func color(for usedFraction: Double) -> Color { let clamped = max(0.0, min(1.0, usedFraction)) for i in 0..<(stops.count - 1) { let s0 = stops[i] let s1 = stops[i + 1] if clamped >= s0.t && clamped <= s1.t { let range = s1.t - s0.t let progress = range > 0 ? (clamped - s0.t) / range : 0.0 let r = s0.r + (s1.r - s0.r) * progress let g = s0.g + (s1.g - s0.g) * progress let b = s0.b + (s1.b - s0.b) * progress return Color(red: r, green: g, blue: b) } } let last = stops.last! return Color(red: last.r, green: last.g, blue: last.b) } } public struct ProviderGroup: Identifiable { public var id: String public var accounts: [ProviderUsage] public var isMuted: Bool } public struct PopoverContentView: View { @ObservedObject public var store: UsageStore public init(store: UsageStore) { self.store = store } private var activeProviders: [ProviderUsage] { store.providers.filter { !$0.isShared } } private var excludedProviders: [ProviderUsage] { store.providers.filter { $0.isShared } } private func group(_ list: [ProviderUsage], isMuted: Bool) -> [ProviderGroup] { var order: [String] = [] var byProvider: [String: [ProviderUsage]] = [:] for item in list { let key = item.effectiveProviderId if byProvider[key] == nil { order.append(key) } byProvider[key, default: []].append(item) } return order.map { key in ProviderGroup(id: "\(isMuted ? "muted" : "active"):\(key)", accounts: byProvider[key] ?? [], isMuted: isMuted) } } public var body: some View { TimelineView(.periodic(from: .now, by: 30)) { timeline in popoverBody(now: timeline.date) } } private func popoverBody(now: Date) -> some View { let activeGroups = group(activeProviders, isMuted: false) let excludedGroups = group(excludedProviders, isMuted: true) return VStack(spacing: 0) { VStack(spacing: 0) { GeometryReader { geo in ZStack(alignment: .leading) { Rectangle() .fill(Color(hex: 0x222222)) .frame(width: geo.size.width, height: 5.0) Rectangle() .fill( store.harmonized.isExhausted ? LinearGradient(colors: [Color(hex: 0xf43f5e), Color(hex: 0xfb7185)], startPoint: .leading, endPoint: .trailing) : LinearGradient( colors: [ UsageGradient.color(for: max(0.0, (store.harmonized.usedPercent - 15.0) / 100.0)), UsageGradient.color(for: store.harmonized.usedPercent / 100.0) ], startPoint: .leading, endPoint: .trailing ) ) .frame(width: max(store.harmonized.usedPercent > 0 ? 6 : 0, geo.size.width * CGFloat(store.harmonized.usedPercent / 100.0)), height: 5.0) .animation(.spring(response: 0.35, dampingFraction: 0.8), value: store.harmonized.usedPercent) } } .frame(height: 5.0) HStack(alignment: .center) { HStack(spacing: 7) { Image(systemName: "gauge.open.with.lines.needle.33percent") .font(.system(size: 11.5, weight: .bold)) .foregroundColor(UsageGradient.color(for: store.harmonized.usedPercent / 100.0)) Text("5-HOUR SESSION") .font(.comicRelief(size: 11, bold: true)) .foregroundColor(Color(hex: 0xcccccc)) .tracking(0.6) } Spacer() HStack(spacing: 4) { Text(String(format: "%.0f%%", store.harmonized.usedPercent)) .font(.comicMono(size: 13, bold: true)) .foregroundColor(UsageGradient.color(for: store.harmonized.usedPercent / 100.0)) Text("USED") .font(.comicRelief(size: 9.5, bold: true)) .foregroundColor(Color(hex: 0x777777)) } } .padding(.horizontal, 16) .padding(.vertical, 9) } .background(Color(hex: 0x141414)) Rectangle() .fill(Color(hex: 0x222222)) .frame(height: 1) VStack(spacing: 0) { ForEach(activeGroups) { grp in ProviderRowView( accounts: grp.accounts, selectedId: store.selectedAccounts[grp.accounts.first?.effectiveProviderId ?? ""], onSelect: { acctId in if let pid = grp.accounts.first?.effectiveProviderId { store.selectedAccounts[pid] = acctId } }, config: store.config, now: now ) } ForEach(excludedGroups) { grp in ProviderRowView( accounts: grp.accounts, selectedId: store.selectedAccounts[grp.accounts.first?.effectiveProviderId ?? ""], onSelect: { acctId in if let pid = grp.accounts.first?.effectiveProviderId { store.selectedAccounts[pid] = acctId } }, config: store.config, isMuted: true, now: now ) } } .padding(.vertical, 4) .padding(.bottom, 6) } .frame(width: 312) .background(Color(hex: 0x0e0e0e)) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 4, style: .continuous) .strokeBorder(Color(hex: 0x26262a), lineWidth: 1) ) } } public struct ProviderRowView: View { public var accounts: [ProviderUsage] public var selectedId: String? public var onSelect: ((String) -> Void)? public var config: AppConfig public var isMuted: Bool public var now: Date @StateObject private var hover = HoverState() public init( accounts: [ProviderUsage], selectedId: String? = nil, onSelect: ((String) -> Void)? = nil, config: AppConfig, isMuted: Bool = false, now: Date = Date() ) { self.accounts = accounts self.selectedId = selectedId self.onSelect = onSelect self.config = config self.isMuted = isMuted self.now = now } public init(provider: ProviderUsage, config: AppConfig, isMuted: Bool = false, now: Date = Date()) { self.init(accounts: [provider], config: config, isMuted: isMuted, now: now) } private var provider: ProviderUsage { if let selectedId, let found = accounts.first(where: { $0.id == selectedId }) { return found } return accounts.first! } public var body: some View { VStack(spacing: 4) { HStack(alignment: .center, spacing: 10) { brandIcon HStack(spacing: 5) { Text(provider.cleanDisplayName) .font(.comicRelief(size: 12.5, bold: true)) .foregroundColor(isMuted ? Color(hex: 0x999999) : Color(hex: 0xeeeeee)) .lineLimit(1) if isMuted { Text("· shared") .font(.comicRelief(size: 9.5)) .foregroundColor(Color(hex: 0x555555)) .lineLimit(1) } } Spacer(minLength: 4) if accounts.count > 1 { accountSwitcher } } VStack(spacing: 4) { if provider.effectiveProviderId == "deepseek" { deepSeekSubRow } else { ForEach(provider.windows) { win in windowSubRow(win) } } // A provider that could not answer says why here. Without this the card is its name and // nothing else, which reads as "no limits" rather than "this did not work". if provider.windows.isEmpty, provider.balance == nil, let error = provider.errorMessage { errorSubRow(error) } } } .padding(.horizontal, 16) .padding(.vertical, 6) .contentShape(Rectangle()) .opacity(isMuted ? 0.45 : 1.0) .onHover { hovering in withAnimation(.easeInOut(duration: 0.12)) { hover.isHovered = hovering } } } private var accountSwitcher: some View { HStack(spacing: 2) { ForEach(accounts) { acct in let isSelected = acct.id == provider.id let remFrac = Harmonizer.effective5HourRemainingFraction(acct) let usedPct = acct.isExhausted ? 100.0 : ((1.0 - remFrac) * 100.0) Button(action: { onSelect?(acct.id) }) { HStack(spacing: 3.5) { Circle() .fill(UsageGradient.color(for: usedPct / 100.0)) .frame(width: 4.5, height: 4.5) Text(acct.shortAccountHandle) .font(.comicRelief(size: 9.5, bold: isSelected)) .foregroundColor(isSelected ? Color(hex: 0xe6e6e6) : Color(hex: 0x6a6a6a)) .lineLimit(1) } .padding(.horizontal, 5) .padding(.vertical, 0.5) .background( RoundedRectangle(cornerRadius: 4, style: .continuous) .fill(isSelected ? Color(hex: 0x1c1c1e) : Color.clear) ) .overlay( RoundedRectangle(cornerRadius: 4, style: .continuous) .strokeBorder(isSelected ? Color(hex: 0x303036) : Color.clear, lineWidth: 0.75) ) } .buttonStyle(.plain) } } } private func windowSubRow(_ win: UsageWindowItem) -> some View { HStack(alignment: .center) { Text(cleanWindowLabel(win)) .font(.comicRelief(size: 10.5)) .foregroundColor(Color(hex: 0x888888)) .lineLimit(1) .truncationMode(.tail) Spacer() HStack(spacing: 12) { if win.usedPercent == 0 && !win.isExhausted { Text("ready") .font(.comicRelief(size: 9.5)) .foregroundColor(Color(hex: 0x10b981).opacity(0.8)) } else if let countdown = win.resetCountdown(at: now) { Text(countdown) .font(.comicMono(size: 9.5)) .foregroundColor(Color(hex: 0x666666)) .lineLimit(1) } let pct = win.isExhausted ? 100.0 : win.usedPercent let remFrac = max(0.0, min(1.0, (100.0 - pct) / 100.0)) BatteryBlockGauge( remainingFraction: remFrac, usedPercent: pct, isExhausted: win.isExhausted, showPercent: hover.isHovered ) .frame(width: 28, alignment: .trailing) } } .padding(.leading, 32) } /// The reason a provider has nothing to show: an unreachable endpoint, a missing CLI, a /// credential this machine does not have. private func errorSubRow(_ message: String) -> some View { HStack(alignment: .top, spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 8.5)) .foregroundColor(Color(hex: 0xf59e0b)) .padding(.top, 1) Text(message) .font(.comicRelief(size: 9.5)) .foregroundColor(Color(hex: 0x8a7f6f)) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } } private var deepSeekSubRow: some View { let cleanBal = (provider.balance ?? "") .replacingOccurrences(of: " USD", with: "") .trimmingCharacters(in: .whitespaces) return HStack(alignment: .center) { Text("Balance") .font(.comicRelief(size: 10.5)) .foregroundColor(Color(hex: 0x888888)) Spacer() HStack(spacing: 12) { if !cleanBal.isEmpty { Text(cleanBal) .font(.comicMono(size: 9.5)) .foregroundColor(Color(hex: 0x666666)) } BatteryBlockGauge( remainingFraction: deepSeekRemainingFraction, usedPercent: deepSeekUsedPercent, isExhausted: deepSeekRemainingFraction <= 0.0, showPercent: hover.isHovered ) .frame(width: 28, alignment: .trailing) } } .padding(.leading, 32) } private var brandIcon: some View { ZStack { RoundedRectangle(cornerRadius: 4, style: .continuous) .fill(Color(hex: 0x161616)) .frame(width: 22, height: 22) if let nsImg = ProviderIcons.image(for: provider.effectiveProviderId) { Image(nsImage: nsImg) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) } else { Image(systemName: provider.sfSymbol) .font(.system(size: 11, weight: .semibold)) .foregroundColor(provider.accentColor) } } } private func cleanWindowLabel(_ win: UsageWindowItem) -> String { let id = win.id.lowercased() let label = win.label.lowercased() if id.contains("gemini") || label.contains("gemini") { return "Gemini" } if id.contains("antigravity:claude") || (label.contains("claude") && (id.contains("antigravity") || label.contains("antigravity"))) { return "Claude" } if let scope = win.scope, !scope.isEmpty, label.contains("(") { return win.label } if win.windowSeconds == 18_000 || id.contains("5h") || label.contains("5-hour") || label.contains("5h") || label == "session" { return "Session (5h)" } if win.windowSeconds == 604_800 || id.contains("7d") || label.contains("7-day") || label == "weekly" { return "Weekly (7d)" } if id.contains("req") || label.contains("request") { return "Requests (14d)" } // "GPT-5.3-Codex-Spark · Primary" is the provider's name for the window; the card is already // titled OpenAI Codex, so the model prefix is the part that can go. It otherwise wraps. if label.contains("spark") { return win.label.replacingOccurrences(of: "GPT-5.3-Codex-Spark", with: "Spark") } return win.label } private var deepSeekBalanceUSD: Double? { Harmonizer.parseBalanceUSD(provider.balance) } private var deepSeekRemainingFraction: Double { guard let bal = deepSeekBalanceUSD, config.deepseekBudgetUSD > 0 else { return 1.0 } return max(0.0, min(1.0, bal / config.deepseekBudgetUSD)) } private var deepSeekUsedPercent: Double { max(0.0, min(100.0, (1.0 - deepSeekRemainingFraction) * 100.0)) } } private final class HoverState: ObservableObject { @Published var isHovered = false } public struct BatteryBlockGauge: View { public var remainingFraction: Double public var usedPercent: Double public var isExhausted: Bool public var showPercent: Bool public var totalBlocks: Int = 5 @StateObject private var hover = HoverState() public init( remainingFraction: Double, usedPercent: Double? = nil, isExhausted: Bool = false, showPercent: Bool = false, totalBlocks: Int = 5 ) { let clampedRem = max(0.0, min(1.0, remainingFraction)) self.remainingFraction = clampedRem self.usedPercent = usedPercent ?? ((1.0 - clampedRem) * 100.0) self.isExhausted = isExhausted || clampedRem <= 0.0 self.showPercent = showPercent self.totalBlocks = totalBlocks } private var blockColor: Color { let usedFraction = max(0.0, min(1.0, usedPercent / 100.0)) return UsageGradient.color(for: usedFraction) } private var strokeColor: Color { isExhausted ? Color(hex: 0xf43f5e).opacity(0.75) : Color(hex: 0x525258) } public var body: some View { HStack(spacing: 1.5) { ZStack { HStack(spacing: 1.5) { ForEach(0..= Double(i) * step + 0.01) RoundedRectangle(cornerRadius: 1.0) .fill(isLit ? blockColor : Color(hex: 0x242428)) .frame(width: 3.8, height: 8.5) } } .opacity((showPercent || hover.isHovered) ? 0.12 : 1.0) if showPercent || hover.isHovered { Text(String(format: "%.0f", isExhausted ? 100.0 : usedPercent)) .font(.comicMono(size: 8.5, bold: true)) .foregroundColor(blockColor) .lineLimit(1) .minimumScaleFactor(0.75) } } .padding(.horizontal, 2.5) .padding(.vertical, 2) .background( RoundedRectangle(cornerRadius: 4, style: .continuous) .fill(Color(hex: 0x121215)) ) .overlay( RoundedRectangle(cornerRadius: 4, style: .continuous) .strokeBorder(strokeColor, lineWidth: 1.0) ) RoundedRectangle(cornerRadius: 0.75) .fill(strokeColor) .frame(width: 1.6, height: 4.5) } .contentShape(Rectangle()) .onHover { hovering in withAnimation(.easeInOut(duration: 0.12)) { hover.isHovered = hovering } } } } extension Color { public init(hex: UInt32, alpha: Double = 1.0) { let r = Double((hex >> 16) & 0xff) / 255.0 let g = Double((hex >> 8) & 0xff) / 255.0 let b = Double(hex & 0xff) / 255.0 self.init(.sRGB, red: r, green: g, blue: b, opacity: alpha) } } extension Font { public static func comicRelief(size: CGFloat, bold: Bool = false) -> Font { if bold { return .custom("ComicRelief-Bold", size: size) } return .custom("Comic Relief", size: size) } public static func comicMono(size: CGFloat, bold: Bool = false) -> Font { if bold { return .custom("ComicMono-Bold", size: size) } return .custom("Comic Mono", size: size) } }