1573 lines · 64.2 KB · Swift
RawDownload
import AppKitimport CodexBarCoreimport SwiftUI /// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels.struct UsageMenuCardView: View {    struct Model {        enum PercentStyle: String {            case left            case used             var labelSuffix: String {                switch self {                case .left: L("usage_percent_suffix_left")                case .used: L("usage_percent_suffix_used")                }            }             var accessibilityLabel: String {                switch self {                case .left: L("Usage remaining")                case .used: L("Usage used")                }            }        }         struct Metric: Identifiable {            let id: String            let title: String            let percent: Double            let percentStyle: PercentStyle            let statusText: String?            let resetText: String?            let detailText: String?            let detailLeftText: String?            let detailRightText: String?            let pacePercent: Double?            let paceOnTop: Bool            let warningMarkerPercents: [Double]            let workdayMarkerPercents: [Double]            let cardStyle: Bool             init(                id: String,                title: String,                percent: Double,                percentStyle: PercentStyle,                statusText: String? = nil,                resetText: String?,                detailText: String?,                detailLeftText: String?,                detailRightText: String?,                pacePercent: Double?,                paceOnTop: Bool,                warningMarkerPercents: [Double] = [],                workdayMarkerPercents: [Double] = [],                cardStyle: Bool = false)            {                self.id = id                self.title = title                self.percent = percent                self.percentStyle = percentStyle                self.statusText = statusText                self.resetText = resetText                self.detailText = detailText                self.detailLeftText = detailLeftText                self.detailRightText = detailRightText                self.pacePercent = pacePercent                self.paceOnTop = paceOnTop                self.warningMarkerPercents = warningMarkerPercents                self.workdayMarkerPercents = workdayMarkerPercents                self.cardStyle = cardStyle            }             var percentLabel: String {                UsageFormatter.percentText(self.percent, suffix: self.percentStyle.labelSuffix)            }        }         enum SubtitleStyle {            case info            case loading            case error        }         struct TokenUsageSection {            let sessionLine: String            let monthLine: String            let comparisonLines: [String]            let hintLine: String?            let errorLine: String?            let errorCopyText: String?             init(                sessionLine: String,                monthLine: String,                comparisonLines: [String] = [],                hintLine: String?,                errorLine: String?,                errorCopyText: String?)            {                self.sessionLine = sessionLine                self.monthLine = monthLine                self.comparisonLines = comparisonLines                self.hintLine = hintLine                self.errorLine = errorLine                self.errorCopyText = errorCopyText            }        }         struct ProviderCostSection {            let title: String            let percentUsed: Double?            let spendLine: String            let percentLine: String?            var personalSpendLine: String?        }         let provider: UsageProvider        let providerName: String        let email: String        let subtitleText: String        let subtitleStyle: SubtitleStyle        var usesLiveSubtitle: Bool = false        let planText: String?        let metrics: [Metric]        let usageNotes: [String]        let openAIAPIUsage: OpenAIAPIUsageSnapshot?        let inlineUsageDashboard: InlineUsageDashboardModel?        let creditsText: String?        let creditsRemaining: Double?        var creditsProgressPercent: Double?, creditsScaleText: String?        let creditsHintText: String?        let creditsHintCopyText: String?        var codexResetCredits: CodexResetCreditsPresentation?        let providerCost: ProviderCostSection?        let tokenUsage: TokenUsageSection?        let placeholder: String?        let progressColor: Color    }     let model: Model    var layoutModel: Model?    let width: CGFloat    var planAction: (() -> Void)?    @Environment(\.menuItemHighlighted) private var isHighlighted    @Environment(\.menuCardRefreshMonitor) private var refreshMonitor     static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String {        if provider == .openrouter, metric.id == "primary" {            return L("API key limit")        }        return metric.title    }     var body: some View {        let liveModel = self.liveModel        VStack(alignment: .leading, spacing: 0) {            UsageMenuCardHeaderView(                model: self.layoutModel ?? self.model,                planAction: self.planAction)             if Self.hasDetails(for: liveModel) {                Divider()                    .padding(.top, UsageMenuCardLayout.headerContentSpacing)                    .padding(.bottom, Self.dividerBottomPadding(for: liveModel))            }             if !liveModel.usesStackedDetailLayout {                if let dashboard = liveModel.inlineUsageDashboard {                    InlineUsageDashboardContent(model: dashboard)                } else if !liveModel.usageNotes.isEmpty {                    UsageNotesContent(notes: liveModel.usageNotes)                } else if let placeholder = liveModel.placeholder {                    // Non-stacked placeholders are standalone detail rows; stacked usage placeholders are gated below.                    Text(placeholder)                        .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))                        .font(.subheadline)                }            } else {                let hasUsage = liveModel.hasUsageContent                let hasCredits = liveModel.creditsText != nil                let hasProviderCost = liveModel.providerCost != nil                let hasCost = liveModel.tokenUsage != nil || hasProviderCost                 VStack(alignment: .leading, spacing: 12) {                    if hasUsage {                        UsageMenuCardUsageContentView(model: liveModel, showBottomDivider: false)                    }                    if hasUsage, hasCredits || hasCost {                        Divider()                    }                    if let credits = liveModel.creditsText {                        CreditsBarContent(                            creditsText: credits,                            creditsRemaining: liveModel.creditsRemaining,                            progressPercent: liveModel.creditsProgressPercent,                            scaleText: liveModel.creditsScaleText,                            hintText: liveModel.creditsHintText,                            hintCopyText: liveModel.creditsHintCopyText,                            progressColor: liveModel.progressColor)                    }                    if hasCredits, hasCost {                        Divider()                    }                    if let providerCost = liveModel.providerCost {                        ProviderCostContent(                            section: providerCost,                            progressColor: liveModel.progressColor)                    }                    if hasProviderCost, liveModel.tokenUsage != nil {                        Divider()                    }                    if let tokenUsage = liveModel.tokenUsage {                        VStack(alignment: .leading, spacing: 6) {                            Text(UsageMenuCardView.Model.tokenUsageHeader(provider: liveModel.provider))                                .font(.body)                                .fontWeight(.medium)                            Text(tokenUsage.sessionLine)                                .font(.footnote)                                .lineLimit(1)                            Text(tokenUsage.monthLine)                                .font(.footnote)                                .lineLimit(1)                            ForEach(tokenUsage.comparisonLines, id: \.self) { line in                                Text(line)                                    .font(.footnote)                                    .lineLimit(1)                            }                            if let hint = tokenUsage.hintLine, !hint.isEmpty {                                Text(hint)                                    .font(.footnote)                                    .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))                                    .lineLimit(4)                                    .fixedSize(horizontal: false, vertical: true)                            }                            if let error = tokenUsage.errorLine, !error.isEmpty {                                Text(error)                                    .font(.footnote)                                    .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))                                    .lineLimit(4)                                    .fixedSize(horizontal: false, vertical: true)                                    .overlay {                                        ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error)                                    }                            }                        }                    }                }            }        }        .padding(.horizontal, UsageMenuCardLayout.horizontalPadding)        .padding(            .top,            Self.hasDetails(for: liveModel)                ? UsageMenuCardLayout.sectionTopPadding                : UsageMenuCardLayout.headerOnlyVerticalPadding)        // AppKit's following separator row adds visual bottom space, so detail cards keep this inset tight.        .padding(            .bottom,            Self.hasDetails(for: liveModel)                ? UsageMenuCardLayout.sectionBottomPadding                : UsageMenuCardLayout.headerOnlyVerticalPadding)        .frame(width: self.width, alignment: .leading)    }     private var liveModel: Model {        guard self.model.usesLiveSubtitle else { return self.model }        return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model    }     private static func hasDetails(for model: Model) -> Bool {        model.hasUsageContent || model.usesStackedDetailLayout    }     static func dividerBottomPadding(for model: Model) -> CGFloat {        if model.usesStackedDetailLayout, model.hasUsageContent {            return UsageMenuCardLayout.postHeaderDividerContentSpacing        }        return UsageMenuCardLayout.sectionBottomPadding    }} private struct UsageMenuCardHeaderView: View {    let model: UsageMenuCardView.Model    var planAction: (() -> Void)?    @Environment(\.menuItemHighlighted) private var isHighlighted    @Environment(\.menuCardRefreshMonitor) private var refreshMonitor     var body: some View {        VStack(alignment: .leading, spacing: UsageMenuCardLayout.headerLineSpacing) {            HStack(alignment: .firstTextBaseline, spacing: UsageMenuCardLayout.headerColumnSpacing) {                Text(self.model.providerName).font(.headline)                    .fontWeight(.semibold)                    .lineLimit(1).truncationMode(.tail).layoutPriority(1)                Spacer()                Text(self.model.email).font(.subheadline)                    .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))                    .lineLimit(1).truncationMode(.middle)            }            let liveSubtitle = self.liveSubtitle            // Keep the geometry AppKit measured for this hosted row. A new error stays one line            // until the next rebuild; a recovered error keeps its reserved height until then.            let usesErrorLayout = self.model.subtitleStyle == .error            let subtitleAlignment: VerticalAlignment = usesErrorLayout ? .top : .firstTextBaseline            HStack(alignment: subtitleAlignment, spacing: UsageMenuCardLayout.headerColumnSpacing) {                if usesErrorLayout {                    Text(self.model.subtitleText)                        .font(.footnote)                        .lineLimit(4)                        .multilineTextAlignment(.leading)                        .fixedSize(horizontal: false, vertical: true)                        .padding(.bottom, 4)                        .hidden()                        .overlay(alignment: .topLeading) {                            Text(liveSubtitle.text)                                .font(.footnote)                                .foregroundStyle(self.subtitleColor(for: liveSubtitle.style))                                .lineLimit(4)                                .multilineTextAlignment(.leading)                                .fixedSize(horizontal: false, vertical: true)                        }                        .clipped()                        .layoutPriority(1)                } else {                    Text(liveSubtitle.text)                        .font(.footnote)                        .foregroundStyle(self.subtitleColor(for: liveSubtitle.style))                        .lineLimit(1)                        .multilineTextAlignment(.leading)                        .fixedSize(horizontal: false, vertical: true)                        .layoutPriority(1)                }                Spacer()                if usesErrorLayout {                    let showsCopyButton = liveSubtitle.style == .error && !liveSubtitle.text.isEmpty                    CopyIconButton(                        copyText: liveSubtitle.text,                        isHighlighted: self.isHighlighted,                        isInteractive: showsCopyButton)                        .opacity(showsCopyButton ? 1 : 0)                        .allowsHitTesting(showsCopyButton)                        .accessibilityHidden(!showsCopyButton)                }                if let plan = self.model.planText {                    Group {                        if let planAction {                            Button(action: planAction) {                                Text(plan)                            }                            .buttonStyle(.plain)                            .menuCardInteractiveControl()                            .accessibilityLabel(plan)                        } else {                            Text(plan)                        }                    }                    .font(.footnote)                    .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))                    .lineLimit(1)                }            }        }    }     private var liveSubtitle: MenuCardLiveSubtitle {        let fallback = MenuCardLiveSubtitle(text: self.model.subtitleText, style: self.model.subtitleStyle)        guard self.model.usesLiveSubtitle else { return fallback }        return self.refreshMonitor?.subtitle(for: self.model.provider, fallback: fallback) ?? fallback    }     private func subtitleColor(for style: UsageMenuCardView.Model.SubtitleStyle) -> Color {        switch style {        case .info: MenuHighlightStyle.secondary(self.isHighlighted)        case .loading: MenuHighlightStyle.secondary(self.isHighlighted)        case .error: MenuHighlightStyle.error(self.isHighlighted)        }    }} private struct CopyIconButtonStyle: ButtonStyle {    let isHighlighted: Bool     func makeBody(configuration: Configuration) -> some View {        configuration.label            .padding(4)            .background {                RoundedRectangle(cornerRadius: 4, style: .continuous)                    .fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(configuration.isPressed ? 0.18 : 0))            }            .scaleEffect(configuration.isPressed ? 0.94 : 1)            .animation(.easeOut(duration: 0.12), value: configuration.isPressed)    }} private struct CopyIconButton: View {    let copyText: String    let isHighlighted: Bool    let isInteractive: Bool     @State private var didCopy = false    @State private var resetTask: Task<Void, Never>?     var body: some View {        Button {            self.handleCopy()        } label: {            Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc")                .font(.caption2.weight(.semibold))                .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))                .frame(width: 18, height: 18)        }        .buttonStyle(CopyIconButtonStyle(isHighlighted: self.isHighlighted))        .menuCardInteractiveControl(isEnabled: self.isInteractive)        .accessibilityLabel(self.didCopy ? L("Copied") : L("Copy error"))    }     private func handleCopy() {        let text = self.copyText        self.resetTask?.cancel()        MenuPasteboardCopy.perform(text, completion: {            self.didCopy = true            self.resetTask = Task { @MainActor in                try? await Task.sleep(for: .seconds(0.9))                self.didCopy = false            }        })    }} private struct ProviderCostContent: View {    let section: UsageMenuCardView.Model.ProviderCostSection    let progressColor: Color    @Environment(\.menuItemHighlighted) private var isHighlighted     var body: some View {        VStack(alignment: .leading, spacing: 6) {            Text(self.section.title)                .font(.body)                .fontWeight(.medium)            if let percentUsed = self.section.percentUsed {                UsageProgressBar(                    percent: percentUsed,                    tint: self.progressColor,                    accessibilityLabel: L("Extra usage spent"))            }            HStack(alignment: .firstTextBaseline) {                Text(self.section.spendLine).font(.footnote).lineLimit(1)                Spacer()                if let percentLine = self.section.percentLine {                    Text(percentLine)                        .font(.footnote)                        .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))                        .lineLimit(1)                }            }            if let personalSpendLine = self.section.personalSpendLine {                Text(personalSpendLine)                    .font(.footnote).foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)).lineLimit(1)            }        }    }} private struct MetricRow: View {    let metric: UsageMenuCardView.Model.Metric    let title: String    let progressColor: Color    @Environment(\.menuItemHighlighted) private var isHighlighted     var body: some View {        VStack(alignment: .leading, spacing: 6) {            Text(self.title)                .font(.body)                .fontWeight(.medium)            if let statusText = self.metric.statusText {                Text(statusText)                    .font(.footnote)                    .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))                    .lineLimit(1)            } else {                UsageProgressBar(                    percent: self.metric.percent,                    tint: self.progressColor,                    accessibilityLabel: self.metric.percentStyle.accessibilityLabel,                    pacePercent: self.metric.pacePercent,                    paceOnTop: self.metric.paceOnTop,                    warningMarkerPercents: self.metric.warningMarkerPercents,                    workdayMarkerPercents: self.metric.workdayMarkerPercents)                VStack(alignment: .leading, spacing: 2) {                    HStack(alignment: .firstTextBaseline) {                        Text(self.metric.percentLabel)                            .font(.footnote)                            .lineLimit(1)                        Spacer()                        if let rightLabel = self.metric.resetText {                            Text(rightLabel)                                .font(.footnote)                                .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))                                .lineLimit(1)

Lines 1–500 of 1573.