From 80a4a5bc554738969e9947460e01b300fe3c7775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AI=E4=BA=A7=E5=93=81=E9=BB=84=E5=8F=94?= Date: Fri, 25 Sep 2026 06:35:37 -0700 Subject: [PATCH 1/5] feat(popover): replace the 900pt popover with a 340pt single column The popover was 900pt wide and macOS centers it under the menu bar item, so on a 13-inch screen with the item left of center its left side was cut off. It is now one 340pt column with the three things worth a glance: - Quotas: one row per window with used percent and reset time, sorted by the most used provider. A marker shows how much of the window has passed, and a badge warns when the current pace runs out before the reset. Beyond two providers the calm ones fold into one row; warnings never fold. - Today: total tokens with the lap ring, and the top three models. - Rank: own rank, the top three with their lead, and how many tokens it takes to pass the next rank, with an estimate at the last hour's pace. Local vs board comparisons only show when both count the same day. Provider icons come from the installed desktop app when present, else a monogram. Share, screenshot, motion mode and quit move into a "..." menu. The client breakdown and full model list stay in the dashboard. The old column cards are removed. Content rules live in CompactPopoverModel, covered by a new fixture check. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 + .../TokenStepSwift/Support/Localization.swift | 60 +++ .../Views/Popover/CompactPopoverModel.swift | 315 +++++++++++ .../Popover/CompactPopoverSections.swift | 489 ++++++++++++++++++ .../Views/Popover/PopoverAgentWorkTable.swift | 94 ---- .../Views/Popover/PopoverFooterView.swift | 185 ------- .../Popover/PopoverModelUsageSection.swift | 230 -------- .../Views/Popover/PopoverQuotaCard.swift | 124 ----- .../Views/Popover/PopoverTodayRingCard.swift | 69 --- .../Views/Popover/PopoverTokenRankCard.swift | 421 --------------- .../Views/PopoverPanelView.swift | 404 ++++++--------- .../Fixtures/CompactPopoverFixtureCheck.swift | 255 +++++++++ .../Tests/Fixtures/PopoverPanelRender.swift | 56 +- .../PopoverModelUsageRowsTests.swift | 93 ---- script/test_all.sh | 1 + script/test_compact_popover.sh | 64 +++ 16 files changed, 1410 insertions(+), 1452 deletions(-) create mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverModel.swift create mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift delete mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverAgentWorkTable.swift delete mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverFooterView.swift delete mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverModelUsageSection.swift delete mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverQuotaCard.swift delete mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTodayRingCard.swift delete mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift create mode 100644 TokenStepSwift/Tests/Fixtures/CompactPopoverFixtureCheck.swift delete mode 100644 TokenStepSwift/Tests/TokenStepSwiftTests/PopoverModelUsageRowsTests.swift create mode 100755 script/test_compact_popover.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 988dec5..f7e0f51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,7 @@ jobs: run: ./script/test_claude_incremental_collector.sh - name: Run settings Codable checks run: ./script/test_settings_codable.sh + - name: Run compact popover checks + run: ./script/test_compact_popover.sh - name: Build TokenStep run: ./script/build_swiftui_and_run.sh --no-launch diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index 907c1c5..53e5484 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -490,6 +490,36 @@ enum TokenStepLocalization { "历史范围": "History range", "%d 天": "%d days", "联网项(除检查更新外默认关闭)": "Network items (off by default except update checks)", + "%@(你)": "%@ (you)", + "%d 分钟后": "in %d min", + "%d 小时 %d 分": "%d h %d min", + "/ %d 人": "/ %d people", + "你是今日第一": "You are #1 today", + "再跑": "Run", + "分享": "Share", + "另有 %d 项正常:%@": "%d more, all normal: %@", + "差 %@": "%@ ahead", + "已用": "Used", + "已用 · %@": "Used · %@", + "引力动效": "Gravity motion", + "按最近 1 小时的速度(约 %@/时),约 %@": "At the last hour's pace (about %@/h), about %@", + "收起": "Collapse", + "明天 %@": "Tomorrow %@", + "更多": "More", + "更多:分享、截图、退出": "More: share, screenshot, quit", + "最近 1 小时没有用量": "No usage in the last hour", + "未进入前 %d 名": "Not in the top %d", + "本地比榜单多 %@,同步后预计升到 #%d": "%@ more locally than on the board; about #%d after sync", + "本地比榜单多 %@,等待同步": "%@ more locally than on the board, waiting for sync", + "榜上 %@": "On board %@", + "榜单 %@": "Board %@", + "照此速度会提前用完": "Runs out early at this pace", + "第 %d 名今日 %@": "#%d today: %@", + "触发坠落脉冲": "Trigger plunge pulse", + "超过 #%d %@": "to pass #%d %@", + "进入前 %d 名": "to enter the top %d", + "退出 TokenStep": "Quit TokenStep", + "领先第二名 %@": "%@ ahead of #2", "近似 · %d 会话": "Approx. · %d sessions", "近似值(按会话开始日汇总)": "Approximate (totals by session start day)", "版本": "Ver", @@ -1150,6 +1180,36 @@ enum TokenStepLocalization { "不开代理 · 不按字数估算 token": "不開代理 · 不按字數估算 token", "不上传用量与内容。Token 统计全部来自本机日志。": "不上傳用量與內容。Token 統計全部來自本機日誌。", "联网项(除检查更新外默认关闭)": "連網項(除檢查更新外預設關閉)", + "%@(你)": "%@(你)", + "%d 分钟后": "%d 分鐘後", + "%d 小时 %d 分": "%d 小時 %d 分", + "/ %d 人": "/ %d 人", + "你是今日第一": "你是今日第一", + "再跑": "再跑", + "分享": "分享", + "另有 %d 项正常:%@": "另有 %d 項正常:%@", + "差 %@": "差 %@", + "已用": "已用", + "已用 · %@": "已用 · %@", + "引力动效": "引力動效", + "按最近 1 小时的速度(约 %@/时),约 %@": "按最近 1 小時的速度(約 %@/時),約 %@", + "收起": "收起", + "明天 %@": "明天 %@", + "更多": "更多", + "更多:分享、截图、退出": "更多:分享、截圖、退出", + "最近 1 小时没有用量": "最近 1 小時沒有用量", + "未进入前 %d 名": "未進入前 %d 名", + "本地比榜单多 %@,同步后预计升到 #%d": "本機比榜單多 %@,同步後預計升到 #%d", + "本地比榜单多 %@,等待同步": "本機比榜單多 %@,等待同步", + "榜上 %@": "榜上 %@", + "榜单 %@": "榜單 %@", + "照此速度会提前用完": "照此速度會提前用完", + "第 %d 名今日 %@": "第 %d 名今日 %@", + "触发坠落脉冲": "觸發墜落脈衝", + "超过 #%d %@": "超過 #%d %@", + "进入前 %d 名": "進入前 %d 名", + "退出 TokenStep": "結束 TokenStep", + "领先第二名 %@": "領先第二名 %@", "近似 · %d 会话": "近似 · %d 會話", "近似值(按会话开始日汇总)": "近似值(按會話開始日彙總)", "版本": "版本", diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverModel.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverModel.swift new file mode 100644 index 0000000..d66ed8c --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverModel.swift @@ -0,0 +1,315 @@ +import Foundation + +/// View-independent content of the compact menu bar popover: which quotas to +/// show and in what order, today's top models, and the rank chase. Kept free of +/// SwiftUI so fixture checks can exercise it without a UI. +enum CompactPopoverModel { + /// Providers shown in full before the calm ones fold into one summary row. + static let expandedProviderLimit = 2 + static let topModelLimit = 3 + + // MARK: - Quotas + + enum QuotaLevel: Equatable { + case normal + case warning + case critical + + init(usedPercent: Double) { + if usedPercent >= 90 { + self = .critical + } else if usedPercent >= 70 { + self = .warning + } else { + self = .normal + } + } + } + + struct QuotaWindowRow: Equatable, Identifiable { + var id: String { kind.rawValue } + var kind: QuotaWindowKind + var title: String + var usedPercent: Double + var level: QuotaLevel + var resetsAt: Date? + /// Share of the window that has passed, 0...1; nil when the window length is unknown. + var elapsedFraction: Double? + /// At the current pace the window runs out before it resets. + var runsOutEarly: Bool + } + + struct QuotaProviderRow: Equatable, Identifiable { + var id: QuotaProviderID { provider } + var provider: QuotaProviderID + var windows: [QuotaWindowRow] + + var worstUsedPercent: Double { + windows.map(\.usedPercent).max() ?? 0 + } + + var level: QuotaLevel { + QuotaLevel(usedPercent: worstUsedPercent) + } + + var runsOutEarly: Bool { + windows.contains(where: \.runsOutEarly) + } + } + + struct QuotaSection: Equatable { + var expanded: [QuotaProviderRow] + /// Calm providers beyond the limit; never includes a warning or critical one. + var folded: [QuotaProviderRow] + + var isEmpty: Bool { + expanded.isEmpty && folded.isEmpty + } + + var worstLevel: QuotaLevel { + (expanded + folded).map(\.level).max { rank($0) < rank($1) } ?? .normal + } + + private func rank(_ level: QuotaLevel) -> Int { + switch level { + case .normal: return 0 + case .warning: return 1 + case .critical: return 2 + } + } + } + + static func quotaSection( + quotas: [ProviderQuota], + now: Date, + calendar: Calendar = TokenStepClock.calendar + ) -> QuotaSection { + let rows = quotas + .filter(\.isAvailable) + .map { quota in + QuotaProviderRow( + provider: quota.provider, + windows: quota.windows.map { windowRow($0, now: now, calendar: calendar) } + ) + } + .sorted { lhs, rhs in + if lhs.worstUsedPercent != rhs.worstUsedPercent { + return lhs.worstUsedPercent > rhs.worstUsedPercent + } + return providerOrder(lhs.provider) < providerOrder(rhs.provider) + } + var expanded: [QuotaProviderRow] = [] + var folded: [QuotaProviderRow] = [] + for row in rows { + if expanded.count < expandedProviderLimit || row.level != .normal || row.runsOutEarly { + expanded.append(row) + } else { + folded.append(row) + } + } + // Folding a single provider saves no space; show it in full instead. + if folded.count == 1 { + expanded.append(contentsOf: folded) + folded = [] + } + return QuotaSection(expanded: expanded, folded: folded) + } + + static func windowRow(_ window: QuotaWindow, now: Date, calendar: Calendar) -> QuotaWindowRow { + let used = min(max(100 - window.remainingPercent, 0), 100) + let elapsed = elapsedFraction(kind: window.kind, resetsAt: window.resetsAt, now: now, calendar: calendar) + let runsOutEarly: Bool + if let elapsed, elapsed >= 0.1, used < 100 { + // Straight-line projection of the current pace to the end of the window. + runsOutEarly = used / 100 > elapsed + } else { + runsOutEarly = false + } + return QuotaWindowRow( + kind: window.kind, + title: window.title, + usedPercent: used, + level: QuotaLevel(usedPercent: used), + resetsAt: window.resetsAt, + elapsedFraction: elapsed, + runsOutEarly: runsOutEarly + ) + } + + /// Only windows with a known fixed length get a pace marker. + static func windowStart(kind: QuotaWindowKind, resetsAt: Date, calendar: Calendar) -> Date? { + switch kind { + case .fiveHour: + return resetsAt.addingTimeInterval(-5 * 60 * 60) + case .sevenDay, .weekly: + return resetsAt.addingTimeInterval(-7 * 24 * 60 * 60) + case .monthlyCredits: + return calendar.date(byAdding: .month, value: -1, to: resetsAt) + case .session, .tokenWindow, .spend, .cursorModels, .otherModels: + return nil + } + } + + static func elapsedFraction( + kind: QuotaWindowKind, + resetsAt: Date?, + now: Date, + calendar: Calendar + ) -> Double? { + guard let resetsAt, + let start = windowStart(kind: kind, resetsAt: resetsAt, calendar: calendar) + else { + return nil + } + let length = resetsAt.timeIntervalSince(start) + guard length > 0 else { return nil } + return min(max(now.timeIntervalSince(start) / length, 0), 1) + } + + private static func providerOrder(_ provider: QuotaProviderID) -> Int { + QuotaProviderID.allCases.firstIndex(of: provider) ?? Int.max + } + + // MARK: - Tokens + + struct ModelRow: Equatable, Identifiable { + var id: String { name } + var name: String + var tokens: Int + /// Bar length relative to the largest model, 0...1. + var share: Double + } + + static func topModels(_ usage: DailyUsage, limit: Int = topModelLimit) -> [ModelRow] { + let sorted = usage.models + .filter { $0.value > 0 } + .sorted { lhs, rhs in + lhs.value != rhs.value ? lhs.value > rhs.value : lhs.key < rhs.key + } + .prefix(limit) + let largest = Double(sorted.first?.value ?? 0) + return sorted.map { name, tokens in + ModelRow(name: name, tokens: tokens, share: largest > 0 ? Double(tokens) / largest : 0) + } + } + + // MARK: - Rank + + /// Token Rank's public board counts days in this zone. + static let rankBoardTimeZoneIdentifier = "Asia/Shanghai" + + struct Podium: Equatable, Identifiable { + var id: Int { rank } + var rank: Int + var name: String + var tokens: Int + var isMe: Bool + /// How far ahead of the viewer this entry is; nil when unknown or not ahead. + var leadOverMe: Int? + } + + enum Chase: Equatable { + /// The next rank up, and how many tokens it takes to pass it. + case next(rank: Int, name: String, gap: Int, progress: Double) + /// Outside the listed ranks: the last listed rank to reach. + case enterBoard(rank: Int, tokens: Int, gap: Int?) + /// First place, and the lead over second. + case leading(lead: Int) + } + + struct RankSection: Equatable { + var myRank: Int? + var rankedUsers: Int + var listedLimit: Int + var myBoardTokens: Int? + var podium: [Podium] + var chase: Chase? + /// Minutes to close the chase gap at the last hour's pace. + var etaMinutes: Int? + var tokensPerHour: Int + /// Local tokens not yet on the board; only when the local day matches the board's. + var unsyncedTokens: Int? + /// Where the local total would place today, when that differs from the board. + var projectedRank: Int? + } + + static func rankSection( + board: TokenRankLeaderboard, + myUserID: Int?, + localTodayTokens: Int, + tokensPerHour: Int, + localTimeZoneIdentifier: String + ) -> RankSection { + let entries = board.entries.sorted { $0.rank < $1.rank } + let mine = myUserID.flatMap { id in entries.first { $0.userID == id } } + let podium = entries.prefix(3).map { entry in + Podium( + rank: entry.rank, + name: entry.name, + tokens: entry.totalTokens, + isMe: entry.userID == mine?.userID, + leadOverMe: mine.flatMap { me in + entry.rank < me.rank ? max(entry.totalTokens - me.totalTokens, 0) : nil + } + ) + } + + let sameDay = localTimeZoneIdentifier == rankBoardTimeZoneIdentifier + var chase: Chase? + var gap: Int? + if let mine { + if let next = entries.last(where: { $0.rank < mine.rank }) { + let needed = max(next.totalTokens - mine.totalTokens, 0) + 1 + gap = needed + let progress = next.totalTokens > 0 ? Double(mine.totalTokens) / Double(next.totalTokens) : 0 + chase = .next(rank: next.rank, name: next.name, gap: needed, progress: min(max(progress, 0), 1)) + } else if let second = entries.first(where: { $0.rank > mine.rank }) { + chase = .leading(lead: max(mine.totalTokens - second.totalTokens, 0)) + } + } else if myUserID != nil, let last = entries.last, entries.count >= board.topLimit { + // Not listed: the board only returns the top `topLimit`. Local tokens are + // comparable only when both count the same day. + let needed = sameDay ? max(last.totalTokens - localTodayTokens, 0) + 1 : nil + gap = needed + chase = .enterBoard(rank: last.rank, tokens: last.totalTokens, gap: needed) + } + + var eta: Int? + if let gap, tokensPerHour > 0 { + eta = Int((Double(gap) / Double(tokensPerHour) * 60).rounded(.up)) + } + + var unsynced: Int? + var projected: Int? + if sameDay, let mine, localTodayTokens > mine.totalTokens { + unsynced = localTodayTokens - mine.totalTokens + let ahead = entries.filter { $0.userID != mine.userID && $0.totalTokens >= localTodayTokens }.count + if ahead + 1 < mine.rank { + projected = ahead + 1 + } + } + + return RankSection( + myRank: mine?.rank, + rankedUsers: board.totalRankedUsers, + listedLimit: board.topLimit, + myBoardTokens: mine?.totalTokens, + podium: podium, + chase: chase, + etaMinutes: eta, + tokensPerHour: tokensPerHour, + unsyncedTokens: unsynced, + projectedRank: projected + ) + } + + /// Tokens used over the last 60 minutes, from today's hourly buckets. The + /// previous hour counts only for the part still inside the window. + static func tokensInLastHour(buckets: [HourlyTokenBucket], now: Date, calendar: Calendar) -> Int { + let hour = calendar.component(.hour, from: now) + let minute = calendar.component(.minute, from: now) + let current = buckets.first { $0.hour == hour }?.tokens ?? 0 + let previous = hour > 0 ? (buckets.first { $0.hour == hour - 1 }?.tokens ?? 0) : 0 + return current + Int(Double(previous) * Double(60 - minute) / 60) + } +} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift new file mode 100644 index 0000000..fc4910f --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift @@ -0,0 +1,489 @@ +import AppKit +import SwiftUI + +// MARK: - Provider icon + +/// The provider's installed app icon when one is found, otherwise a monogram +/// tile in the provider's color. Icons come from the user's own apps at +/// runtime; none are bundled. +struct QuotaProviderIcon: View { + var provider: QuotaProviderID + var size: CGFloat = 20 + + var body: some View { + if let image = Self.appIcon(for: provider) { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .frame(width: size, height: size) + .accessibilityHidden(true) + } else { + RoundedRectangle(cornerRadius: size * 0.25, style: .continuous) + .fill(AgentSourceRegistry.color(for: provider.rawValue)) + .frame(width: size, height: size) + .overlay { + Text(Self.monogram(for: provider)) + .font(.system(size: size * 0.42, weight: .bold, design: .rounded)) + .foregroundStyle(Color.white) + } + .accessibilityHidden(true) + } + } + + static func monogram(for provider: QuotaProviderID) -> String { + switch provider { + case .codex: return "Cx" + case .claude: return "Cl" + case .cursor: return "Cu" + case .glm: return "GL" + case .kimi: return "Km" + case .grok: return "Gk" + } + } + + /// Bundle identifiers of each provider's desktop app, most specific first. + private static let bundleIdentifiers: [QuotaProviderID: [String]] = [ + .codex: ["com.openai.codex", "com.openai.chat"], + .claude: ["com.anthropic.claudefordesktop"], + .cursor: ["com.todesktop.230313mzl4w4u92"] + ] + + private static var iconCache: [QuotaProviderID: NSImage?] = [:] + + static func appIcon(for provider: QuotaProviderID) -> NSImage? { + if let cached = iconCache[provider] { + return cached + } + let icon = (bundleIdentifiers[provider] ?? []) + .lazy + .compactMap { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) } + .first + .map { NSWorkspace.shared.icon(forFile: $0.path) } + iconCache[provider] = .some(icon) + return icon + } +} + +// MARK: - Shared styling + +enum CompactPopoverStyle { + static let width: CGFloat = 340 + static let horizontalPadding: CGFloat = 16 + + static func color(for level: CompactPopoverModel.QuotaLevel) -> Color { + switch level { + case .normal: return Color.tokenGreen + case .warning: return Color.tokenWarning + case .critical: return Color(red: 201 / 255, green: 60 / 255, blue: 55 / 255) + } + } + + static func textColor(for level: CompactPopoverModel.QuotaLevel) -> Color { + level == .normal ? Color.tokenInk : color(for: level) + } + + static func percent(_ value: Double) -> String { + "\(Int(value.rounded()))%" + } + + static func minutes(_ value: Int) -> String { + if value < 60 { + return LFormat("%d 分钟", value) + } + let hours = value / 60 + let rest = value % 60 + return rest == 0 ? LFormat("%d 小时", hours) : LFormat("%d 小时 %d 分", hours, rest) + } + + static func relative(_ date: Date, now: Date = Date()) -> String { + let seconds = now.timeIntervalSince(date) + if seconds < 60 { + return L("刚刚") + } + let formatter = RelativeDateTimeFormatter() + formatter.locale = TokenStepLocalization.locale + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: now) + } + + static func resetText(_ date: Date?, now: Date = Date()) -> String? { + guard let date else { return nil } + let calendar = TokenStepClock.calendar + let minutes = Int(date.timeIntervalSince(now) / 60) + if minutes < 1 { + return L("即将重置") + } + if minutes < 60 { + return LFormat("%d 分钟后", minutes) + } + let time = DateFormatter() + time.locale = TokenStepLocalization.locale + time.timeZone = TokenStepClock.timeZone + time.setLocalizedDateFormatFromTemplate("HHmm") + if calendar.isDate(date, inSameDayAs: now) { + return LFormat("今天 %@", time.string(from: date)) + } + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), + calendar.isDate(date, inSameDayAs: tomorrow) { + // "Tomorrow 17:01" does not fit the column in English; a weekday does. + if TokenStepLocalization.language == .en { + time.setLocalizedDateFormatFromTemplate("EEEHHmm") + return time.string(from: date) + } + return LFormat("明天 %@", time.string(from: date)) + } + let day = DateFormatter() + day.locale = TokenStepLocalization.locale + day.timeZone = TokenStepClock.timeZone + day.setLocalizedDateFormatFromTemplate("MMMd") + return day.string(from: date) + } +} + +private struct CompactSectionHeader: View { + var title: String + var trailing: String? + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Color.tokenInk.opacity(0.62)) + Spacer(minLength: 8) + if let trailing { + Text(trailing) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + } +} + +private struct CompactBar: View { + var fraction: Double + var color: Color + var height: CGFloat = 6 + var marker: Double? + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(Color.tokenTrack) + Capsule() + .fill(color) + .frame(width: max(proxy.size.width * min(max(fraction, 0), 1), fraction > 0 ? height : 0)) + if let marker { + RoundedRectangle(cornerRadius: 1) + .fill(Color.tokenInk.opacity(0.55)) + .frame(width: 2, height: height + 6) + .offset(x: proxy.size.width * min(max(marker, 0), 1) - 1) + } + } + .frame(height: proxy.size.height) + } + .frame(height: height) + } +} + +// MARK: - Quota section + +struct CompactQuotaSection: View { + var section: CompactPopoverModel.QuotaSection + var updatedAt: Date? + @State private var showsFolded = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + CompactSectionHeader( + title: L("额度"), + trailing: updatedAt.map { LFormat("已用 · %@", CompactPopoverStyle.relative($0)) } ?? L("已用") + ) + ForEach(section.expanded) { provider in + providerBlock(provider) + } + if showsFolded { + ForEach(section.folded) { provider in + providerBlock(provider) + } + } + if !section.folded.isEmpty { + Button { + showsFolded.toggle() + } label: { + HStack(spacing: 8) { + Text(showsFolded ? L("收起") : foldedSummary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Image(systemName: showsFolded ? "chevron.up" : "chevron.down") + .font(.system(size: 10, weight: .bold)) + } + .font(.system(size: 12)) + .foregroundStyle(Color.tokenInk.opacity(0.74)) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, minHeight: 32) + .background(Color.tokenTrack.opacity(0.55), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel(showsFolded ? L("收起") : foldedSummary) + } + } + } + + private var foldedSummary: String { + LFormat( + "另有 %d 项正常:%@", + section.folded.count, + section.folded.map(\.provider.displayName) + .joined(separator: TokenStepLocalization.language == .en ? ", " : "、") + ) + } + + private func providerBlock(_ provider: CompactPopoverModel.QuotaProviderRow) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 8) { + QuotaProviderIcon(provider: provider.provider) + Text(provider.provider.displayName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + Spacer(minLength: 4) + if provider.runsOutEarly { + Text(L("照此速度会提前用完")) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(CompactPopoverStyle.color(for: .warning)) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(CompactPopoverStyle.color(for: .warning).opacity(0.14), in: Capsule()) + } + } + ForEach(provider.windows) { window in + HStack(spacing: 8) { + Text(window.title) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .frame(width: 58, alignment: .leading) + .lineLimit(1) + CompactBar( + fraction: window.usedPercent / 100, + color: CompactPopoverStyle.color(for: window.level), + marker: window.elapsedFraction + ) + Text(CompactPopoverStyle.percent(window.usedPercent)) + .font(.system(size: 12, weight: .bold)) + .monospacedDigit() + .foregroundStyle(CompactPopoverStyle.textColor(for: window.level)) + .frame(width: 38, alignment: .trailing) + Text(CompactPopoverStyle.resetText(window.resetsAt) ?? "") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 72, alignment: .trailing) + } + .accessibilityElement(children: .combine) + } + } + } +} + +// MARK: - Token section + +struct CompactTokenSection: View { + var usage: DailyUsage + var lap: TokenStepLapProgress + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + ZStack { + Circle().stroke(Color.tokenTrack, lineWidth: 5) + Circle() + .trim(from: 0, to: lap.currentLapProgress) + .stroke(lap.ringColor, style: StrokeStyle(lineWidth: 5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 42, height: 42) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(L("今日 Token")) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Text(TokenStepFormat.tokens(usage.totalTokens, compact: true)) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color.tokenInk) + } + Spacer(minLength: 4) + VStack(alignment: .trailing, spacing: 2) { + Text(lap.lapStatusText) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.tokenGreenDark) + Text(lap.perLapGoalText) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + ForEach(CompactPopoverModel.topModels(usage)) { model in + HStack(spacing: 10) { + Text(model.name) + .font(.system(size: 12)) + .foregroundStyle(Color.tokenInk) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 112, alignment: .leading) + CompactBar(fraction: model.share, color: Color.tokenGreen.opacity(0.85), height: 5) + Text(TokenStepFormat.tokens(model.tokens, compact: true)) + .font(.system(size: 12, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(Color.tokenInk) + .frame(width: 56, alignment: .trailing) + } + .accessibilityElement(children: .combine) + } + } + } +} + +// MARK: - Rank section + +struct CompactRankSection: View { + var section: CompactPopoverModel.RankSection + var fetchedAt: Date + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + CompactSectionHeader(title: L("今日排名"), trailing: LFormat("榜单 %@", CompactPopoverStyle.relative(fetchedAt))) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(section.myRank.map { "#\($0)" } ?? "—") + .font(.system(size: 26, weight: .heavy, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color.tokenInk) + Text(rankCaption) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer(minLength: 4) + if let board = section.myBoardTokens { + Text(LFormat("榜上 %@", TokenStepFormat.tokens(board, compact: true))) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } + ForEach(section.podium) { entry in + HStack(spacing: 8) { + Text("\(entry.rank)") + .font(.system(size: 11, weight: .heavy, design: .rounded)) + .foregroundStyle(Color.black.opacity(0.78)) + .frame(width: 20, height: 20) + .background(medal(entry.rank), in: Circle()) + Text(entry.isMe ? LFormat("%@(你)", entry.name) : entry.name) + .font(.system(size: 12, weight: entry.isMe ? .bold : .regular)) + .foregroundStyle(Color.tokenInk) + .lineLimit(1) + Spacer(minLength: 4) + Text(TokenStepFormat.tokens(entry.tokens, compact: true)) + .font(.system(size: 12, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(Color.tokenInk) + Text(entry.leadOverMe.map { LFormat("差 %@", TokenStepFormat.tokens($0, compact: true)) } ?? "") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .frame(width: 70, alignment: .trailing) + } + .accessibilityElement(children: .combine) + } + if let chase = section.chase { + chaseCard(chase) + } + } + } + + private var rankCaption: String { + if section.myRank == nil, section.chase != nil { + return LFormat("未进入前 %d 名", section.listedLimit) + } + return LFormat("/ %d 人", section.rankedUsers) + } + + private func medal(_ rank: Int) -> Color { + switch rank { + case 1: return Color(red: 242 / 255, green: 209 / 255, blue: 107 / 255) + case 2: return Color(red: 213 / 255, green: 219 / 255, blue: 225 / 255) + default: return Color(red: 230 / 255, green: 185 / 255, blue: 143 / 255) + } + } + + @ViewBuilder + private func chaseCard(_ chase: CompactPopoverModel.Chase) -> some View { + VStack(alignment: .leading, spacing: 6) { + switch chase { + case let .next(rank, name, gap, progress): + chaseHeadline(gap: gap, target: LFormat("超过 #%d %@", rank, name)) + CompactBar(fraction: progress, color: Color.tokenGreen, height: 5) + paceLine + case let .enterBoard(rank, tokens, gap): + if let gap { + chaseHeadline(gap: gap, target: LFormat("进入前 %d 名", rank)) + paceLine + } else { + Text(LFormat("第 %d 名今日 %@", rank, TokenStepFormat.tokens(tokens, compact: true))) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.tokenGreenDark) + } + case let .leading(lead): + Text(L("你是今日第一")) + .font(.system(size: 14, weight: .heavy)) + .foregroundStyle(Color.tokenGreenDark) + Text(LFormat("领先第二名 %@", TokenStepFormat.tokens(lead, compact: true))) + .font(.system(size: 11)) + .foregroundStyle(Color.tokenGreenDark.opacity(0.85)) + } + if let unsynced = section.unsyncedTokens { + Text(section.projectedRank.map { + LFormat("本地比榜单多 %@,同步后预计升到 #%d", TokenStepFormat.tokens(unsynced, compact: true), $0) + } ?? LFormat("本地比榜单多 %@,等待同步", TokenStepFormat.tokens(unsynced, compact: true))) + .font(.system(size: 11)) + .foregroundStyle(Color.tokenGreenDark.opacity(0.85)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.tokenGreen.opacity(0.10), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.tokenGreen.opacity(0.28)) + } + } + + private func chaseHeadline(gap: Int, target: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(L("再跑")) + .font(.system(size: 13, weight: .semibold)) + Text(TokenStepFormat.tokens(gap, compact: true)) + .font(.system(size: 18, weight: .heavy, design: .rounded)) + .monospacedDigit() + Text(target) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + } + .foregroundStyle(Color.tokenGreenDark) + } + + @ViewBuilder + private var paceLine: some View { + if let eta = section.etaMinutes { + Text(LFormat( + "按最近 1 小时的速度(约 %@/时),约 %@", + TokenStepFormat.tokens(section.tokensPerHour, compact: true), + CompactPopoverStyle.minutes(eta) + )) + .font(.system(size: 11)) + .foregroundStyle(Color.tokenGreenDark.opacity(0.85)) + } else { + Text(L("最近 1 小时没有用量")) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } +} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverAgentWorkTable.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverAgentWorkTable.swift deleted file mode 100644 index 26b7ec4..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverAgentWorkTable.swift +++ /dev/null @@ -1,94 +0,0 @@ -import SwiftUI - -struct PopoverAgentWorkTable: View { - @EnvironmentObject private var appState: AppState - - private var work: DailyAgentWork { - appState.todayAgentWork - } - - var body: some View { - let voyage = TokenStepThemeRuntime.isCinematic - return VStack(alignment: .leading, spacing: 0) { - HStack { - Text(L("Agent 用量")) - .font((voyage ? Font.callout : Font.caption).weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer() - Text(LFormat("%d 个来源", rows.count)) - .font((voyage ? Font.caption : Font.caption2).weight(.bold)) - .foregroundStyle(.secondary) - } - .frame(height: 20) - - if rows.isEmpty { - Text(L("今日还没有 Agent 记录")) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 32, alignment: .leading) - } else { - header - .frame(height: 18) - ForEach(visibleRows) { row in - rowView(row) - } - } - } - .padding(.horizontal, voyage ? 14 : 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .top) - .contentShape(Rectangle()) - .onTapGesture { - MainWindowPresenter.shared.show(appState: appState, section: .today) - } - } - - private var header: some View { - let voyage = TokenStepThemeRuntime.isCinematic - return HStack(spacing: 10) { - Text(L("来源")).frame(maxWidth: .infinity, alignment: .leading) - Text(L("Token")).frame(width: voyage ? 88 : 72, alignment: .trailing) - } - .font(.system(size: voyage ? 11.5 : 10.5, weight: .semibold)) - .foregroundStyle(.secondary) - } - - private func rowView(_ row: AgentWorkSource) -> some View { - let voyage = TokenStepThemeRuntime.isCinematic - return HStack(alignment: .center, spacing: 10) { - HStack(alignment: .center, spacing: 6) { - Circle() - .fill(tokenToolColor(row.source)) - .frame(width: 6, height: 6) - Text(row.source) - .lineLimit(1) - .truncationMode(.middle) - .multilineTextAlignment(.leading) - .help(row.source) - } - .frame(maxWidth: .infinity, alignment: .leading) - Text(TokenStepFormat.tokens(row.tokens, compact: true)) - .frame(width: voyage ? 88 : 72, alignment: .trailing) - .monospacedDigit() - } - .font(.system(size: voyage ? 11.5 : 10.5, weight: .semibold)) - .foregroundStyle(Color.tokenInk.opacity(0.82)) - .frame(height: 25) - .overlay(alignment: .top) { - Rectangle().fill(Color.tokenDivider).frame(height: 1) - } - } - - private var rows: [AgentWorkSource] { - work.sources - .filter { $0.tokens > 0 } - .sorted { - if $0.tokens != $1.tokens { return $0.tokens > $1.tokens } - return $0.source.localizedStandardCompare($1.source) == .orderedAscending - } - } - - private var visibleRows: [AgentWorkSource] { - Array(rows.prefix(3)) - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverFooterView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverFooterView.swift deleted file mode 100644 index 8aca293..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverFooterView.swift +++ /dev/null @@ -1,185 +0,0 @@ -import AppKit -import SwiftUI - -struct PopoverFooterView: View { - @EnvironmentObject private var appState: AppState - - var body: some View { - Group { - if TokenStepThemeRuntime.isCinematic { - odysseyFooter - } else { - classicFooter - } - } - } - - private var classicFooter: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Label(L("本地统计"), systemImage: "checkmark.shield.fill") - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - Spacer() - Text(appState.settings.refreshIntervalSeconds == 0 ? L("手动刷新") : LFormat("刷新 %@", TokenStepFormat.intervalLabel(appState.settings.refreshIntervalSeconds))) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - } - - HStack(spacing: 10) { - Button { - MainWindowPresenter.shared.show(appState: appState) - } label: { - Label(L("打开仪表盘"), systemImage: "arrow.up.right") - .font(.system(size: 14, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenActionText) - .frame(width: 148, height: 40) - .background(Color.tokenGreen, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(.plain) - .help(L("打开仪表盘")) - - Spacer(minLength: 0) - - PopoverActionButton(title: L("刷新"), symbol: "arrow.clockwise") { - appState.refresh() - } - .disabled(appState.isRefreshing) - - PopoverUpdateActionButton() - - PopoverActionButton(title: L("设置"), symbol: "gearshape") { - SettingsWindowPresenter.shared.show(appState: appState) - } - - PopoverActionButton(title: L("退出"), symbol: "power") { - NSApplication.shared.terminate(nil) - } - } - } - } - - private var odysseyFooter: some View { - HStack(alignment: .bottom, spacing: 18) { - VStack(alignment: .leading, spacing: 10) { - Label(L("本地统计"), systemImage: "checkmark.shield.fill") - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - - Button { - MainWindowPresenter.shared.show(appState: appState) - } label: { - Label(L("打开仪表盘"), systemImage: "arrow.up.right") - .font(.system(size: 15, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenActionText) - .frame(width: 190, height: 44) - .background( - LinearGradient( - colors: [Color.tokenGreenDark, Color.tokenGreen], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - in: RoundedRectangle(cornerRadius: 12, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.white.opacity(0.18), lineWidth: 0.8) - } - .shadow(color: Color.black.opacity(0.28), radius: 12, y: 6) - } - .buttonStyle(.plain) - .help(L("打开仪表盘")) - } - - Spacer(minLength: 0) - - VStack(alignment: .trailing, spacing: 10) { - Text(appState.settings.refreshIntervalSeconds == 0 ? L("手动刷新") : LFormat("刷新 %@", TokenStepFormat.intervalLabel(appState.settings.refreshIntervalSeconds))) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - - HStack(spacing: 10) { - PopoverActionButton(title: L("刷新"), symbol: "arrow.clockwise") { - appState.refresh() - } - .disabled(appState.isRefreshing) - - PopoverUpdateActionButton() - - PopoverActionButton(title: L("设置"), symbol: "gearshape") { - SettingsWindowPresenter.shared.show(appState: appState) - } - - PopoverActionButton(title: L("退出"), symbol: "power") { - NSApplication.shared.terminate(nil) - } - } - } - } - } -} - -private struct PopoverUpdateActionButton: View { - @EnvironmentObject private var appState: AppState - - var body: some View { - Button { - appState.showUpdateDetails() - } label: { - ZStack { - if state.isChecking { - ProgressView() - .controlSize(.small) - .tint(state.tint) - } else { - Image(systemName: state.symbol) - .font(.system(size: 14, weight: .heavy)) - .foregroundStyle(state.tint) - } - - if state.isAvailable { - Circle() - .fill(Color.tokenGreen) - .frame(width: 5.5, height: 5.5) - .offset(x: 12, y: -12) - } - } - .frame(width: 40, height: 40) - .background(Color.tokenSurface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(state.isAvailable ? Color.tokenGreen.opacity(0.56) : Color.tokenHairline) - } - .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(.plain) - .disabled(state.isChecking) - .help(state.help) - .accessibilityLabel(state.accessibilityLabel) - } - - private var state: UpdateActionVisualState { - appState.updateActionVisualState - } -} - -private struct PopoverActionButton: View { - var title: String - var symbol: String - var action: () -> Void - - var body: some View { - Button(action: action) { - Image(systemName: symbol) - .font(.system(size: 14, weight: .heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.78)) - .frame(width: 40, height: 40) - .background(Color.tokenSurface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.tokenHairline)) - .help(title) - } - .buttonStyle(.plain) - .help(title) - .accessibilityLabel(title) - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverModelUsageSection.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverModelUsageSection.swift deleted file mode 100644 index 0fabac2..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverModelUsageSection.swift +++ /dev/null @@ -1,230 +0,0 @@ -import SwiftUI - -struct PopoverModelUsageRow: Identifiable, Equatable { - enum Kind: Equatable { - case model(String) - case other - } - - var kind: Kind - var tokens: Int - var percent: Double - var colorSlot: Int? - - var id: String { - switch kind { - case let .model(model): return "model:\(model)" - case .other: return "aggregate:other" - } - } - - var originalModelName: String? { - guard case let .model(model) = kind else { return nil } - return model - } -} -enum PopoverModelUsageState: Equatable { - case hidden - case waiting - case rows([PopoverModelUsageRow], mismatch: Bool) -} - -enum PopoverModelUsageRows { - static let maximumIndividualRows = 3 - static let maximumVisibleRows = 4 - - static func state(from usage: DailyUsage) -> PopoverModelUsageState { - guard usage.totalTokens > 0 else { return .hidden } - let sourceRows = TodayModelUsageRows.allPositiveRows(from: usage) - guard !sourceRows.isEmpty else { return .waiting } - - var rows = sourceRows.prefix(maximumIndividualRows).map { source in - PopoverModelUsageRow( - kind: .model(source.model), - tokens: source.tokens, - percent: source.percent, - colorSlot: TodayModelUsageRows.colorSlot(for: source.model) - ) - } - - let remaining = sourceRows.dropFirst(maximumIndividualRows) - if !remaining.isEmpty { - let otherTokens = saturatedSum(remaining.map(\.tokens)) - rows.append( - PopoverModelUsageRow( - kind: .other, - tokens: otherTokens, - percent: percent(tokens: otherTokens, total: usage.totalTokens), - colorSlot: nil - ) - ) - } - - return .rows( - rows, - mismatch: TodayModelUsageRows.hasTokenTotalMismatch(usage) - ) - } - - private static func percent(tokens: Int, total: Int) -> Double { - guard total > 0 else { return 0 } - return min(100, max(0, Double(tokens) * 100 / Double(total))) - } - - private static func saturatedSum(_ values: [Int]) -> Int { - values.reduce(into: 0) { result, value in - let (sum, overflow) = result.addingReportingOverflow(value) - result = overflow ? Int.max : sum - } - } -} - -struct PopoverModelUsageSection: View { - var usage: DailyUsage - - private var state: PopoverModelUsageState { - PopoverModelUsageRows.state(from: usage) - } - - @ViewBuilder - var body: some View { - switch state { - case .hidden: - EmptyView() - case .waiting: - content(rows: [], mismatch: false, waiting: true) - case let .rows(rows, mismatch): - content(rows: rows, mismatch: mismatch, waiting: false) - } - } - - private func content( - rows: [PopoverModelUsageRow], - mismatch: Bool, - waiting: Bool - ) -> some View { - VStack(alignment: .leading, spacing: 0) { - header(mismatch: mismatch) - .frame(height: 20) - - if waiting { - Text(L("模型明细同步中")) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 32, alignment: .leading) - .accessibilityLabel(L("模型明细同步中")) - } else { - columnHeader - .frame(height: 18) - ForEach(rows) { row in - modelRow(row) - } - } - } - .padding(.horizontal, TokenStepThemeRuntime.isCinematic ? 14 : 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - - private func header(mismatch: Bool) -> some View { - HStack(spacing: 7) { - Text(L("今日模型")) - .font((TokenStepThemeRuntime.isCinematic ? Font.callout : Font.caption).weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer(minLength: 4) - if mismatch { - Image(systemName: "info.circle") - .font(.system(size: 10.5, weight: .bold)) - .foregroundStyle(.secondary) - .help(L("模型明细合计与今日总量不一致;占比按今日总量计算。")) - .accessibilityLabel(L("模型明细合计与今日总量不一致;占比按今日总量计算。")) - } - } - } - - private var columnHeader: some View { - HStack(spacing: 8) { - Text(L("模型")) - .frame(maxWidth: .infinity, alignment: .leading) - Text("Token") - .frame(width: 68, alignment: .trailing) - Text(L("占比")) - .frame(width: 44, alignment: .trailing) - } - .font(.system(size: 9.5, weight: .semibold)) - .foregroundStyle(.secondary) - } - - private func modelRow(_ row: PopoverModelUsageRow) -> some View { - let color = rowColor(row) - let name = displayName(row) - return VStack(spacing: 3) { - HStack(spacing: 8) { - HStack(spacing: 6) { - Circle() - .fill(color) - .frame(width: 6, height: 6) - Text(name) - .lineLimit(1) - .truncationMode(.middle) - .help(row.originalModelName ?? name) - } - .frame(maxWidth: .infinity, alignment: .leading) - - Text(TokenStepFormat.tokens(row.tokens, compact: true)) - .frame(width: 68, alignment: .trailing) - .monospacedDigit() - Text(TokenStepFormat.percent(row.percent)) - .frame(width: 44, alignment: .trailing) - .monospacedDigit() - } - .font(.system(size: TokenStepThemeRuntime.isCinematic ? 10.5 : 10, weight: .semibold)) - .foregroundStyle(Color.tokenInk.opacity(0.82)) - - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule().fill(Color.tokenTrack.opacity(0.78)) - Capsule() - .fill(color) - .frame(width: max(row.tokens > 0 ? 3 : 0, proxy.size.width * row.percent / 100)) - } - } - .frame(height: 3) - .padding(.leading, 12) - } - .frame(height: 25) - .accessibilityElement(children: .ignore) - .accessibilityLabel( - "\(name), \(TokenStepFormat.tokens(row.tokens, compact: true)), \(TokenStepFormat.percent(row.percent))" - ) - } - - private func displayName(_ row: PopoverModelUsageRow) -> String { - switch row.kind { - case .other: - return L("其他") - case let .model(model): - return model.lowercased() == "unknown" ? L("未知模型") : model - } - } - - private func rowColor(_ row: PopoverModelUsageRow) -> Color { - guard let slot = row.colorSlot else { - return Color.secondary.opacity(0.72) - } - if TokenStepThemeRuntime.isCinematic { - switch slot { - case 0: return .tokenGreen - case 1: return TokenStepThemeRuntime.palette.activity3.color - case 2: return TokenStepThemeRuntime.palette.activity2.color - default: return .tokenGreenDark - } - } - switch slot { - case 0: return .tokenGreen - case 1: return Color(red: 0.19, green: 0.52, blue: 0.91) - case 2: return Color.tokenInk.opacity(0.55) - default: return .tokenGreenDark - } - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverQuotaCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverQuotaCard.swift deleted file mode 100644 index 61c0f42..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverQuotaCard.swift +++ /dev/null @@ -1,124 +0,0 @@ -import SwiftUI - -struct PopoverQuotaCard: View { - @EnvironmentObject private var appState: AppState - - var body: some View { - let voyage = TokenStepThemeRuntime.isCinematic - return VStack(alignment: .leading, spacing: voyage ? 12 : 10) { - HStack(spacing: 8) { - Text(L("订阅额度")) - .font((voyage ? Font.callout : Font.caption).weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer() - if appState.isRefreshingCodexQuota { - ProgressView() - .controlSize(.small) - .scaleEffect(0.72) - } else if let fetchedAt = latestFetchedAt { - Text(quotaFetchedText(fetchedAt)) - .font((voyage ? Font.caption : Font.caption2).weight(.bold)) - .foregroundStyle(.secondary) - } - } - - if visible.isEmpty { - Text(L("暂未读取到额度")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } else if usesGrid { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { - ForEach(visible) { quota in - quotaChip(quota) - } - } - } else { - VStack(spacing: voyage ? 6 : 8) { - ForEach(visible) { quota in - quotaChip(quota) - } - } - } - } - .padding(14) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - - private var visible: [ProviderQuota] { - appState.visibleQuotas.filter(\.isAvailable) - } - - private var usesGrid: Bool { - visible.count >= 4 - } - - private var latestFetchedAt: Date? { - visible.compactMap(\.fetchedAt).max() - } - - private func quotaChip(_ quota: ProviderQuota) -> some View { - let voyage = TokenStepThemeRuntime.isCinematic - return VStack(alignment: .leading, spacing: voyage ? 4 : 6) { - Text(quota.provider.displayName) - .font((voyage ? Font.system(size: 11.5) : Font.caption2).weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.76)) - if quota.isAvailable { - ForEach(quota.windows.prefix(2)) { window in - quotaRow(window) - } - } else { - Text(statusText(quota)) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(voyage ? 6 : 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.tokenSurface.opacity(voyage ? 0.84 : 1), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(Color.tokenHairline)) - } - - private func quotaRow(_ window: QuotaWindow) -> some View { - let voyage = TokenStepThemeRuntime.isCinematic - return VStack(alignment: .leading, spacing: voyage ? 3 : 4) { - HStack { - Text(window.kind.shortTitle) - .font(.system(size: voyage ? 10.5 : 10.5, weight: .heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.62)) - Spacer() - Text(LFormat("剩余 %@", TokenStepFormat.percent(window.remainingPercent))) - .font(.system(size: voyage ? 10.5 : 10.5, weight: .heavy)) - .foregroundStyle(window.isLow ? Color.tokenWarning : Color.tokenInk.opacity(0.82)) - } - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule().fill(Color.tokenTrack.opacity(0.82)) - Capsule() - .fill(window.isLow ? Color.tokenWarning : Color.tokenGreen) - .frame(width: max(5, proxy.size.width * window.remainingPercent / 100)) - } - } - .frame(height: voyage ? 4 : 5) - } - } - - private func statusText(_ quota: ProviderQuota) -> String { - switch quota.status { - case .notLoggedIn: - return quota.provider == .cursor ? L("未登录 Cursor") : L("未登录") - case .wrongKeyType: - return L("当前 key 非订阅计划") - case .needsLogin: - return L("需要 grok login") - case .unavailable, .available: - return quota.message ?? L("暂不可用") - } - } - - private func quotaFetchedText(_ date: Date) -> String { - let seconds = max(0, Int(Date().timeIntervalSince(date).rounded())) - if seconds < 60 { return L("刚刚") } - return LFormat("%d 分钟前", max(1, seconds / 60)) - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTodayRingCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTodayRingCard.swift deleted file mode 100644 index d506433..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTodayRingCard.swift +++ /dev/null @@ -1,69 +0,0 @@ -import SwiftUI - -struct PopoverTodayRingCard: View { - @EnvironmentObject private var appState: AppState - - var body: some View { - let lap = appState.todayLap - let voyage = TokenStepThemeRuntime.isCinematic - let ringSize: CGFloat = voyage ? 164 : 118 - let lineWidth: CGFloat = voyage ? 14 : 12 - - return VStack(spacing: voyage ? 13 : 10) { - HStack { - Text(L("今日消耗")) - .font((voyage ? Font.callout : Font.caption).weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer() - Text(appState.today.date.suffix(5)) - .font((voyage ? Font.caption : Font.caption2).weight(.bold)) - .foregroundStyle(.secondary) - } - - ZStack { - if TokenStepThemeRuntime.isVoyage { - Circle() - .fill(Color.tokenCanvas.opacity(0.42)) - .padding(12) - .blur(radius: 0.2) - VoyageBowProgressView(progress: lap.currentLapProgress, lineWidth: lineWidth, color: lap.ringColor) - } else if TokenStepThemeRuntime.isInterstellar { - Circle() - .fill(Color.black.opacity(0.52)) - .padding(12) - ProgressRingView(progress: lap.currentLapProgress, lineWidth: lineWidth, color: lap.ringColor) - .overlay { - InterstellarEventHorizonEmblem() - .padding(30) - .opacity(0.50) - } - } else { - ProgressRingView(progress: lap.currentLapProgress, lineWidth: lineWidth, color: lap.ringColor) - } - VStack(spacing: 2) { - Text(TokenStepFormat.tokens(appState.today.totalTokens, compact: true)) - .font(.system(size: voyage ? 24 : 18, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .minimumScaleFactor(0.62) - .lineLimit(1) - Text(LFormat("/ %@", TokenStepFormat.tokens(appState.settings.dailyGoalTokens, compact: true))) - .font((voyage ? Font.caption : Font.caption2).weight(.bold)) - .foregroundStyle(.secondary) - } - .frame(width: voyage ? 118 : 88) - } - .frame(width: ringSize, height: ringSize) - - VStack(spacing: voyage ? 5 : 3) { - Text(lap.lapStatusText) - .font((voyage ? Font.callout : Font.caption).weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(TokenStepFormat.money(appState.today.cost)) - .font((voyage ? Font.caption : Font.caption2).weight(.bold)) - .foregroundStyle(.secondary) - } - } - .padding(voyage ? 18 : 14) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift deleted file mode 100644 index 3c17181..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift +++ /dev/null @@ -1,421 +0,0 @@ -import SwiftUI - -struct PopoverTokenRankCard: View { - enum Layout { - case card - case ribbon - } - - @EnvironmentObject private var appState: AppState - @Environment(\.isScreenshotRendering) private var isScreenshotRendering - @State private var userRankFrame: CGRect = .zero - var layout: Layout = .card - - var body: some View { - Group { - switch layout { - case .card: - cardContent - case .ribbon: - ribbonContent - } - } - .coordinateSpace(name: "tokenRankCard") - .contentShape(RoundedRectangle(cornerRadius: layout == .ribbon ? 16 : 24, style: .continuous)) - .onPreferenceChange(TokenRankUserRowFrameKey.self) { frame in - userRankFrame = frame - } - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .named("tokenRankCard")) - .onEnded { value in - let dx = abs(value.location.x - value.startLocation.x) - let dy = abs(value.location.y - value.startLocation.y) - guard dx < 4, dy < 4 else { return } - if appState.agentWorkRankIdentity != nil, userRankFrame.contains(value.location) { - appState.openTokenRankUserPage() - } else { - appState.openTokenRankLeaderboardPage() - } - } - ) - .onAppear { - if !isScreenshotRendering { - appState.refreshTokenRank() - } - } - } - - private var cardContent: some View { - VStack(alignment: .leading, spacing: 10) { - header - userRankContent - metaContent - } - .padding(14) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - - private var ribbonContent: some View { - HStack(spacing: 14) { - HStack(spacing: 8) { - Circle() - .fill(Color.tokenGreen) - .frame(width: 8, height: 8) - VStack(alignment: .leading, spacing: 3) { - Text(L("Agent 消耗榜")) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - if appState.isRefreshingTokenRank { - Text(L("同步中")) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - } else if let fetchedAt = appState.tokenRank?.fetchedAt { - Text(headerStatus(fetchedAt)) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - } - } - } - .frame(width: 150, alignment: .leading) - - ribbonDivider - - HStack(spacing: 10) { - Image(systemName: mainSymbol) - .font(.system(size: 18, weight: .heavy)) - .foregroundStyle(mainTint) - .frame(width: 36, height: 36) - .background(mainTint.opacity(0.16), in: Circle()) - - VStack(alignment: .leading, spacing: 2) { - if let entry = currentUserEntry { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("#\(entry.rank)") - .font(.system(size: 25, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .monospacedDigit() - Text(entry.name) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - if let rankContext { - Text(rankContext) - .font(.caption2.weight(.bold)) - .foregroundStyle(mainTint) - .lineLimit(1) - } - } else { - Text(mainTitle) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(mainSubtitle) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .background( - GeometryReader { proxy in - Color.clear.preference( - key: TokenRankUserRowFrameKey.self, - value: proxy.frame(in: .named("tokenRankCard")) - ) - } - ) - - ribbonDivider - ribbonMetric( - label: L("全榜今日消耗"), - value: totalRankTokensText, - detail: appState.tokenRank.map { LFormat("%d 人参榜", $0.totalRankedUsers) } - ) - .frame(width: 154, alignment: .leading) - - ribbonDivider - ribbonMetric( - label: L("我的今日"), - value: currentUserEntry.map { TokenStepFormat.tokens($0.totalTokens, compact: true) } ?? L("等待同步"), - detail: currentUserEntry.map { LFormat("主力 %@", primaryClientName($0)) } - ) - .frame(width: 130, alignment: .leading) - - Image(systemName: "arrow.up.right") - .font(.system(size: 13, weight: .heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.48)) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .help(appState.agentWorkRankIdentity == nil ? L("打开榜单页") : L("打开个人页")) - } - - private var ribbonDivider: some View { - Rectangle() - .fill(Color.tokenDivider.opacity(0.74)) - .frame(width: 1, height: 38) - } - - private func ribbonMetric(label: String, value: String, detail: String?) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - Text(value) - .font(.system(size: 16, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .lineLimit(1) - .minimumScaleFactor(0.70) - .monospacedDigit() - if let detail { - Text(detail) - .font(.system(size: 9.5, weight: .bold)) - .foregroundStyle(Color.tokenInk.opacity(0.54)) - .lineLimit(1) - } - } - } - - private var header: some View { - HStack(spacing: 8) { - Circle() - .fill(Color.tokenGreen) - .frame(width: 8, height: 8) - Text(L("Agent 消耗榜")) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer() - if appState.isRefreshingTokenRank { - ProgressView() - .controlSize(.small) - .scaleEffect(0.72) - } else if let fetchedAt = appState.tokenRank?.fetchedAt { - Text(headerStatus(fetchedAt)) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - } - } - } - - private var userRankContent: some View { - HStack(alignment: .center, spacing: 12) { - Image(systemName: mainSymbol) - .font(.system(size: 18, weight: .heavy)) - .foregroundStyle(mainTint) - .frame(width: 38, height: 38) - .background(mainTint.opacity(0.16), in: Circle()) - - VStack(alignment: .leading, spacing: 3) { - if let entry = currentUserEntry { - Text(L("今日排名")) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - Text("#\(entry.rank)") - .font(.system(size: 28, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .monospacedDigit() - } else { - Text(mainTitle) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - .lineLimit(1) - .minimumScaleFactor(0.78) - } - Text(mainSubtitle) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .lineLimit(1) - if let rankContext { - Text(rankContext) - .font(.caption2.weight(.bold)) - .foregroundStyle(mainTint) - .lineLimit(1) - } - } - - Spacer(minLength: 0) - Image(systemName: "arrow.up.right") - .font(.system(size: 13, weight: .heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.48)) - } - .contentShape(Rectangle()) - .background( - GeometryReader { proxy in - Color.clear.preference( - key: TokenRankUserRowFrameKey.self, - value: proxy.frame(in: .named("tokenRankCard")) - ) - } - ) - .help(appState.agentWorkRankIdentity == nil ? L("打开榜单页") : L("打开个人页")) - } - - @ViewBuilder - private var metaContent: some View { - if let error = appState.tokenRankError, appState.tokenRank == nil { - HStack(spacing: 8) { - Image(systemName: "wifi.exclamationmark") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.secondary) - Text(error) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - .lineLimit(1) - Spacer(minLength: 0) - } - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background(Color.tokenTrack.opacity(0.30), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - } else { - VStack(alignment: .leading, spacing: 8) { - VStack(alignment: .leading, spacing: 4) { - Text(L("全榜今日消耗")) - .font(.system(size: 10.5, weight: .heavy)) - .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.62) : Color.white.opacity(0.62)) - Text(totalRankTokensText) - .font(.system(size: 22, weight: .heavy, design: .rounded)) - .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk : Color.white) - .monospacedDigit() - if let count = appState.tokenRank?.totalRankedUsers, count > 0 { - Text(LFormat("%d 人参榜", count)) - .font(.caption2.weight(.bold)) - .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.62) : Color.white.opacity(0.62)) - } - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - TokenStepThemeRuntime.isCinematic - ? Color.tokenGreen.opacity(0.11) - : Color(red: 0.12, green: 0.18, blue: 0.14), - in: RoundedRectangle(cornerRadius: 12, style: .continuous) - ) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.tokenHairline)) - - if let entry = currentUserEntry { - TokenRankMetaPill(label: L("我的今日"), value: TokenStepFormat.tokens(entry.totalTokens, compact: true)) - } - - if let error = appState.tokenRankError { - Text(error) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } - } - - private var currentUserEntry: TokenRankEntry? { - guard let identity = appState.agentWorkRankIdentity else { return nil } - return appState.tokenRank?.entry(matching: identity.id) - } - - private var mainTitle: String { - if let entry = currentUserEntry { - return LFormat("今日排名 #%d", entry.rank) - } - if appState.agentWorkRankIdentity == nil { - return L("尚未关联") - } - if appState.tokenRank != nil { - return L("今日未上榜") - } - if appState.tokenRankError != nil { - return L("榜单暂不可用") - } - return L("Agent 消耗榜") - } - - private var mainSubtitle: String { - if let entry = currentUserEntry { - return "\(entry.name) · \(L("主力")) \(primaryClientName(entry))" - } - if let identity = appState.agentWorkRankIdentity { - return identity.name - } - return L("安装 Token Rank 后自动识别") - } - - private var mainSymbol: String { - if currentUserEntry != nil { return "trophy.fill" } - if appState.agentWorkRankIdentity == nil { return "person.crop.circle.badge.questionmark" } - if appState.tokenRank == nil, appState.tokenRankError != nil { return "exclamationmark.triangle.fill" } - return "list.number" - } - - private var mainTint: Color { - appState.tokenRank == nil && appState.tokenRankError != nil ? .secondary : .tokenGreen - } - - private var rankContext: String? { - guard let entry = currentUserEntry, - let leaderboard = appState.tokenRank, - leaderboard.totalRankedUsers > 0 else { return nil } - let percentile = max( - 0, - min(100, Int((Double(leaderboard.totalRankedUsers - entry.rank) / Double(leaderboard.totalRankedUsers) * 100).rounded())) - ) - return LFormat( - "第 %d / %d · 超过 %d%% 参榜用户", - entry.rank, - leaderboard.totalRankedUsers, - percentile - ) - } - - private var totalRankTokensText: String { - guard let leaderboard = appState.tokenRank else { return L("等待同步") } - return TokenStepFormat.tokens(leaderboard.totalTokens, compact: true) - } - - private func primaryClientName(_ entry: TokenRankEntry) -> String { - guard let client = entry.clients.max(by: { $0.value < $1.value })?.key else { - return L("全部工具") - } - return AgentSourceRegistry.displayName(for: client) - } - - private func relativeTime(_ date: Date) -> String { - let seconds = max(0, Int(Date().timeIntervalSince(date).rounded())) - if seconds < 60 { return L("刚刚") } - return LFormat("%d 分钟前", max(1, seconds / 60)) - } - - private func headerStatus(_ date: Date) -> String { - let time = relativeTime(date) - guard let count = appState.tokenRank?.totalRankedUsers, count > 0 else { return time } - return LFormat("%d 人 · %@", count, time) - } -} - -private struct TokenRankMetaPill: View { - var label: String - var value: String - - var body: some View { - HStack(spacing: 6) { - Text(label) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - Text(value) - .font(.caption.weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.78)) - .lineLimit(1) - .minimumScaleFactor(0.74) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(Color.tokenTrack.opacity(0.30), in: Capsule()) - } -} - -private struct TokenRankUserRowFrameKey: PreferenceKey { - static var defaultValue: CGRect = .zero - - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift index 86f3fda..2b17ce9 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift @@ -10,19 +10,26 @@ struct PopoverPanelView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - header - if TokenStepThemeRuntime.isCinematic { - cinematicContent - } else { - classicColumns + let quotaSection = CompactPopoverModel.quotaSection(quotas: appState.visibleQuotas, now: Date()) + if !quotaSection.isEmpty { + section { + CompactQuotaSection(section: quotaSection, updatedAt: quotaUpdatedAt) + } + sectionDivider + } + section { + CompactTokenSection(usage: appState.today, lap: appState.todayLap) + } + if let rank = rankSection, let board = appState.tokenRank { + sectionDivider + section { + CompactRankSection(section: rank, fetchedAt: board.fetchedAt) + } } notices - PopoverFooterView() - .padding(.horizontal, TokenStepThemeRuntime.isCinematic ? 22 : 16) - .padding(.top, TokenStepThemeRuntime.isCinematic ? 14 : 12) - .padding(.bottom, TokenStepThemeRuntime.isCinematic ? 20 : 14) + footer } - .frame(width: 900) + .frame(width: CompactPopoverStyle.width) .background { if TokenStepThemeRuntime.isInterstellar { InterstellarBackdrop( @@ -66,180 +73,185 @@ struct PopoverPanelView: View { } } - private var header: some View { - HStack(spacing: 10) { - TokenStepBrandLockup( - markSize: TokenStepThemeRuntime.isCinematic ? 34 : 28, - titleSize: TokenStepThemeRuntime.isCinematic ? 20 : 17 - ) - Spacer() - if TokenStepThemeRuntime.isInterstellar, - InterstellarMotionLabConfiguration.isEnabled { - InterstellarMotionLabPicker( - mode: $interstellarMotionMode, - triggerPulse: { - interstellarManualPulseTrigger &+= 1 + /// Cinematic themes put each section on a translucent panel so text stays + /// readable over the artwork; the classic theme draws sections directly. + @ViewBuilder + private func section(@ViewBuilder _ content: () -> Content) -> some View { + if TokenStepThemeRuntime.isCinematic { + content() + .padding(12) + .background { + if TokenStepThemeRuntime.isInterstellar { + InterstellarPanelBackground(opacity: 0.72, cornerRadius: 14) + } else { + OdysseyPopoverSectionBackground(opacity: 0.56, cornerRadius: 14) } - ) - } - HStack(spacing: 6) { - Circle() - .fill(appState.isRefreshing ? Color.secondary.opacity(0.68) : Color.tokenSuccess) - .frame(width: 7, height: 7) - Text(appState.isRefreshing ? L("同步中") : L("已同步")) - .font(.caption.weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.72)) - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.tokenSurface, in: Capsule()) - .overlay(Capsule().stroke(Color.tokenDivider)) - - if !isScreenshotRendering { - PopoverCaptureMenuButton( - shareTodayAction: { copyShareCard(.today) }, - shareYesterdayAction: { copyShareCard(.yesterday) }, - shareYesterdayRhythmAction: copyYesterdayRhythmCard, - downloadTodayAction: { downloadShareCard(.today) }, - downloadYesterdayRhythmAction: downloadYesterdayRhythmCard, - copyPopoverAction: copyPopoverScreenshot, - savePopoverAction: savePopoverScreenshot - ) - } - } - .padding(.horizontal, TokenStepThemeRuntime.isCinematic ? 26 : 16) - .frame(height: TokenStepThemeRuntime.isCinematic ? 72 : 48) - .background { - if TokenStepThemeRuntime.isCinematic { - LinearGradient( - colors: [Color.black.opacity(0.30), Color.tokenCanvas.opacity(0.12), Color.black.opacity(0.16)], - startPoint: .leading, - endPoint: .trailing - ) - } - } - .overlay(alignment: .bottom) { - if TokenStepThemeRuntime.isCinematic { - LinearGradient( - colors: [Color.tokenGreen.opacity(0.58), Color.tokenDivider.opacity(0.34), Color.clear], - startPoint: .leading, - endPoint: .trailing - ) - .frame(height: 1) - .padding(.horizontal, 22) - } - } - } - - private var cinematicContent: some View { - VStack(spacing: 10) { - HStack(alignment: .top, spacing: 10) { - PopoverTodayRingCard() - .frame(width: 250, height: odysseyTopCardHeight) - .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.66 : 0.16)) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - - centralUsageCard - .frame(minWidth: 330, maxWidth: .infinity, minHeight: odysseyTopCardHeight, maxHeight: odysseyTopCardHeight) - .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.70 : 0.46)) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - - if appState.showsQuotaColumn { - PopoverQuotaCard() - .frame(width: 260, height: odysseyTopCardHeight) - .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.74 : 0.54)) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } - } - - if appState.shouldShowAgentWorkRank, appState.agentWorkRankIdentity != nil { - PopoverTokenRankCard(layout: .ribbon) - .frame(height: 76) - .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.76 : 0.58, cornerRadius: 16)) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, 10) + .padding(.top, 10) + } else { + content() + .padding(.horizontal, CompactPopoverStyle.horizontalPadding) + .padding(.top, 14) + .padding(.bottom, 12) } - .padding(.horizontal, 20) - .padding(.top, 12) } - private var classicColumns: some View { - HStack(alignment: .top, spacing: 0) { - PopoverTodayRingCard() - .frame(width: 188) - columnDivider - centralUsageCard - .frame(minWidth: 240, maxWidth: .infinity) - if appState.showsQuotaColumn { - columnDivider - PopoverQuotaCard() - .frame(width: quotaColumnWidth) - } - if appState.shouldShowAgentWorkRank, appState.agentWorkRankIdentity != nil { - columnDivider - PopoverTokenRankCard() - .frame(width: 196) - } - } - .frame(minHeight: columnsMinHeight) - .overlay(alignment: .top) { + @ViewBuilder + private var sectionDivider: some View { + if !TokenStepThemeRuntime.isCinematic { Rectangle() .fill(Color.tokenDivider) .frame(height: 1) + .padding(.horizontal, CompactPopoverStyle.horizontalPadding) } } - private var quotaColumnWidth: CGFloat { - appState.visibleQuotas.count >= 4 ? 248 : 208 + private var quotaUpdatedAt: Date? { + appState.visibleQuotas.compactMap(\.fetchedAt).max() } - private var odysseyTopCardHeight: CGFloat { 288 } - - @ViewBuilder - private func cinematicSectionBackground(opacity: Double, cornerRadius: CGFloat = 18) -> some View { - if TokenStepThemeRuntime.isInterstellar { - InterstellarPanelBackground(opacity: opacity, cornerRadius: cornerRadius) - } else { - OdysseyPopoverSectionBackground(opacity: opacity, cornerRadius: cornerRadius) + private var rankSection: CompactPopoverModel.RankSection? { + guard appState.shouldShowAgentWorkRank, + let identity = appState.agentWorkRankIdentity, + let board = appState.tokenRank + else { + return nil } + let now = Date() + let buckets = appState.snapshot.rhythm(for: appState.today.date)?.buckets ?? [] + return CompactPopoverModel.rankSection( + board: board, + myUserID: identity.id, + localTodayTokens: appState.today.totalTokens, + tokensPerHour: CompactPopoverModel.tokensInLastHour(buckets: buckets, now: now, calendar: TokenStepClock.calendar), + localTimeZoneIdentifier: TokenStepClock.identifier + ) } - private var showsModelUsageSection: Bool { - switch PopoverModelUsageRows.state(from: appState.today) { - case .hidden: return false - case .waiting, .rows: return true - } - } + private var footer: some View { + HStack(spacing: 4) { + Button { + appState.refresh() + } label: { + Group { + if appState.isRefreshing { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 13, weight: .semibold)) + } + } + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(appState.isRefreshing) + .help(appState.isRefreshing ? L("同步中") : L("刷新")) + .accessibilityLabel(L("刷新")) - private var centralUsageCard: some View { - let voyage = TokenStepThemeRuntime.isCinematic - return VStack(spacing: 0) { - PopoverAgentWorkTable() - .frame(height: voyage ? (showsModelUsageSection ? 128 : odysseyTopCardHeight) : nil) + Button { + MainWindowPresenter.shared.show(appState: appState) + } label: { + Label(L("打开仪表盘"), systemImage: "rectangle.on.rectangle") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.tokenGreenDark) + .padding(.horizontal, 10) + .frame(height: 32) + .background(Color.tokenGreen.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + + Spacer(minLength: 4) + + Text(appState.settings.refreshIntervalSeconds == 0 + ? L("手动刷新") + : LFormat("刷新 %@", TokenStepFormat.intervalLabel(appState.settings.refreshIntervalSeconds))) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + + if appState.availableUpdate != nil || appState.isCheckingForUpdates { + let update = appState.updateActionVisualState + Button { + appState.showUpdateDetails() + } label: { + Image(systemName: update.symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(update.tint) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(update.isChecking) + .help(update.help) + .accessibilityLabel(update.accessibilityLabel) + } - if showsModelUsageSection { - Rectangle() - .fill(Color.tokenDivider) - .frame(height: 1) - .padding(.horizontal, voyage ? 14 : 12) + Button { + SettingsWindowPresenter.shared.show(appState: appState) + } label: { + Image(systemName: "gearshape") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(L("设置")) + .accessibilityLabel(L("设置")) - PopoverModelUsageSection(usage: appState.today) + if !isScreenshotRendering { + moreMenu } } - .frame(maxWidth: .infinity, maxHeight: voyage ? .infinity : nil, alignment: .top) - .contentShape(Rectangle()) - .onTapGesture { - MainWindowPresenter.shared.show(appState: appState, section: .today) + .foregroundStyle(Color.tokenInk.opacity(0.78)) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(TokenStepThemeRuntime.isCinematic ? Color.clear : Color.tokenCanvas.opacity(0.6)) + .overlay(alignment: .top) { + if !TokenStepThemeRuntime.isCinematic { + Rectangle().fill(Color.tokenDivider).frame(height: 1) + } } + .padding(.top, TokenStepThemeRuntime.isCinematic ? 6 : 0) } - private var columnsMinHeight: CGFloat { - appState.visibleQuotas.count >= 5 ? 300 : 248 - } - - private var columnDivider: some View { - Rectangle().fill(Color.tokenDivider) - .frame(width: 1) + private var moreMenu: some View { + Menu { + Section(L("分享")) { + Button(L("分享昨日节奏"), systemImage: "waveform.path.ecg") { copyYesterdayRhythmCard() } + Button(L("分享昨日成绩"), systemImage: "calendar.badge.clock") { copyShareCard(.yesterday) } + Button(L("分享今日卡片"), systemImage: "sun.max.fill") { copyShareCard(.today) } + Button(L("下载昨日节奏"), systemImage: "arrow.down.heart.fill") { downloadYesterdayRhythmCard() } + Button(L("下载今日卡片"), systemImage: "arrow.down.circle.fill") { downloadShareCard(.today) } + } + Section(L("截图")) { + Button(L("复制浮层截图"), systemImage: "doc.on.clipboard") { copyPopoverScreenshot() } + Button(L("保存浮层 PNG"), systemImage: "square.and.arrow.down") { savePopoverScreenshot() } + } + if TokenStepThemeRuntime.isInterstellar, InterstellarMotionLabConfiguration.isEnabled { + Picker(L("引力动效"), selection: $interstellarMotionMode) { + ForEach(InterstellarMotionMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + Button(L("触发坠落脉冲"), systemImage: "waveform") { interstellarManualPulseTrigger &+= 1 } + } + Divider() + Button(L("退出 TokenStep"), systemImage: "power") { NSApplication.shared.terminate(nil) } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .menuStyle(.button) + .menuIndicator(.hidden) + .buttonStyle(.plain) + .fixedSize() + .help(L("更多")) + .accessibilityLabel(L("更多:分享、截图、退出")) } @ViewBuilder @@ -400,75 +412,3 @@ struct PopoverPanelView: View { return (day, rhythm, previousDay(before: day)) } } - -private struct PopoverCaptureMenuButton: View { - var shareTodayAction: () -> Void - var shareYesterdayAction: () -> Void - var shareYesterdayRhythmAction: () -> Void - var downloadTodayAction: () -> Void - var downloadYesterdayRhythmAction: () -> Void - var copyPopoverAction: () -> Void - var savePopoverAction: () -> Void - - var body: some View { - Menu { - Button { - shareYesterdayRhythmAction() - } label: { - Label(L("分享昨日节奏"), systemImage: "waveform.path.ecg") - } - - Button { - shareYesterdayAction() - } label: { - Label(L("分享昨日成绩"), systemImage: "calendar.badge.clock") - } - - Button { - shareTodayAction() - } label: { - Label(L("分享今日卡片"), systemImage: "sun.max.fill") - } - - Divider() - - Button { - downloadYesterdayRhythmAction() - } label: { - Label(L("下载昨日节奏"), systemImage: "arrow.down.heart.fill") - } - - Button { - downloadTodayAction() - } label: { - Label(L("下载今日卡片"), systemImage: "arrow.down.circle.fill") - } - - Divider() - - Button { - copyPopoverAction() - } label: { - Label(L("复制浮层截图"), systemImage: "doc.on.clipboard") - } - - Button { - savePopoverAction() - } label: { - Label(L("保存浮层 PNG"), systemImage: "square.and.arrow.down") - } - } label: { - Image(systemName: "camera.fill") - .font(.system(size: 13, weight: .heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.76)) - .frame(width: 30, height: 30) - .background(Color.tokenSurface, in: Circle()) - .overlay(Circle().stroke(Color.tokenHairline)) - .contentShape(Circle()) - } - .menuStyle(.button) - .buttonStyle(.plain) - .help(L("截图与分享")) - .accessibilityLabel(L("截图与分享")) - } -} diff --git a/TokenStepSwift/Tests/Fixtures/CompactPopoverFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/CompactPopoverFixtureCheck.swift new file mode 100644 index 0000000..5acbc1a --- /dev/null +++ b/TokenStepSwift/Tests/Fixtures/CompactPopoverFixtureCheck.swift @@ -0,0 +1,255 @@ +import Darwin +import Foundation + +@main +struct CompactPopoverFixtureCheck { + static var calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! + return calendar + }() + static let now = ISO8601DateFormatter().date(from: "2026-09-25T04:57:00Z")! + + static func main() { + do { + try checkOrderingAndFolding() + try checkUrgentProvidersNeverFold() + try checkSingleFoldedProviderStaysExpanded() + try checkPaceProjection() + try checkWindowLengths() + try checkLevels() + try checkTopModels() + try checkChaseNextRank() + try checkLeader() + try checkOutsideBoard() + try checkUnsyncedOnlyOnSameDay() + try checkLastHourRate() + print("Compact popover fixture checks passed") + } catch { + fputs("Compact popover fixture failed: \(error)\n", stderr) + exit(1) + } + } + + // MARK: Quotas + + private static func checkOrderingAndFolding() throws { + let section = CompactPopoverModel.quotaSection( + quotas: [ + quota(.grok, [(.monthlyCredits, 32, nil)]), + quota(.codex, [(.fiveHour, 67, hours(0.2)), (.sevenDay, 69, hours(34.5))]), + quota(.glm, [(.monthlyCredits, 24, nil)]), + quota(.kimi, [(.weekly, 12, nil)]), + quota(.claude, [(.fiveHour, 58, nil)]), + ProviderQuota.unavailable(.cursor) + ], + now: now, + calendar: calendar + ) + try expect(section.expanded.map(\.provider) == [.codex, .claude], "two worst providers expanded: \(section.expanded.map(\.provider))") + try expect(section.folded.map(\.provider) == [.grok, .glm, .kimi], "calm providers fold in order: \(section.folded.map(\.provider))") + } + + private static func checkUrgentProvidersNeverFold() throws { + let section = CompactPopoverModel.quotaSection( + quotas: [ + quota(.codex, [(.fiveHour, 95, nil)]), + quota(.claude, [(.fiveHour, 91, nil)]), + quota(.grok, [(.monthlyCredits, 75, nil)]), + quota(.glm, [(.monthlyCredits, 10, nil)]), + quota(.kimi, [(.weekly, 5, nil)]) + ], + now: now, + calendar: calendar + ) + try expect(section.expanded.map(\.provider) == [.codex, .claude, .grok], "a warning provider stays expanded past the limit") + try expect(section.worstLevel == .critical, "worst level is critical") + } + + private static func checkSingleFoldedProviderStaysExpanded() throws { + let section = CompactPopoverModel.quotaSection( + quotas: [ + quota(.codex, [(.fiveHour, 40, nil)]), + quota(.claude, [(.fiveHour, 30, nil)]), + quota(.grok, [(.monthlyCredits, 20, nil)]) + ], + now: now, + calendar: calendar + ) + try expect(section.expanded.count == 3 && section.folded.isEmpty, "one foldable provider is shown in full") + } + + private static func checkPaceProjection() throws { + // 7 days, resets in 34.5 hours: 79.5% of the window has passed. + let week = row(.sevenDay, used: 89, resetsIn: hours(34.5)) + try expect(abs((week.elapsedFraction ?? 0) - 0.7946) < 0.001, "7-day elapsed \(String(describing: week.elapsedFraction))") + try expect(week.runsOutEarly, "89% used at 79% elapsed runs out early") + let session = row(.fiveHour, used: 67, resetsIn: hours(11.0 / 60)) + try expect(!session.runsOutEarly, "67% used at 96% elapsed is on pace") + let fresh = row(.fiveHour, used: 8, resetsIn: hours(4.8)) + try expect(!fresh.runsOutEarly, "the first 10% of a window is never flagged") + let exhausted = row(.fiveHour, used: 100, resetsIn: hours(1)) + try expect(!exhausted.runsOutEarly, "an exhausted window is not a projection") + } + + private static func checkWindowLengths() throws { + try expect(row(.session, used: 50, resetsIn: hours(1)).elapsedFraction == nil, "unknown session length has no marker") + try expect(row(.cursorModels, used: 50, resetsIn: hours(1)).elapsedFraction == nil, "cursor model window has no marker") + try expect(row(.fiveHour, used: 50, resetsIn: nil).elapsedFraction == nil, "no reset time has no marker") + // Resets 2026-10-01 00:00 Shanghai; the window began 2026-09-01 00:00. + let reset = ISO8601DateFormatter().date(from: "2026-09-30T16:00:00Z")! + let monthly = CompactPopoverModel.windowRow( + QuotaWindow(kind: .monthlyCredits, usedPercent: 50, resetsAt: reset), + now: now, + calendar: calendar + ) + let expected = now.timeIntervalSince(ISO8601DateFormatter().date(from: "2026-08-31T16:00:00Z")!) / (30 * 24 * 3600) + try expect(abs((monthly.elapsedFraction ?? 0) - expected) < 0.0001, "monthly window uses the calendar month") + } + + private static func checkLevels() throws { + try expect(CompactPopoverModel.QuotaLevel(usedPercent: 69.9) == .normal, "69.9 is normal") + try expect(CompactPopoverModel.QuotaLevel(usedPercent: 70) == .warning, "70 is warning") + try expect(CompactPopoverModel.QuotaLevel(usedPercent: 89.9) == .warning, "89.9 is warning") + try expect(CompactPopoverModel.QuotaLevel(usedPercent: 90) == .critical, "90 is critical") + } + + // MARK: Tokens + + private static func checkTopModels() throws { + let usage = DailyUsage( + date: "2026-09-25", + tools: [:], + models: ["GLM-5.3": 24_050_000, "GLM-5.3-Flash": 4_350_000, "gpt-6-luna": 280_000, "b-model": 120_000, "a-model": 120_000, "idle": 0], + totalTokens: 28_920_000, + cost: 0 + ) + let rows = CompactPopoverModel.topModels(usage) + try expect(rows.map(\.name) == ["GLM-5.3", "GLM-5.3-Flash", "gpt-6-luna"], "top three by tokens: \(rows.map(\.name))") + try expect(rows.first?.share == 1 && abs(rows[1].share - 4_350_000.0 / 24_050_000) < 1e-9, "shares are relative to the largest") + let tie = CompactPopoverModel.topModels(usage, limit: 5).suffix(2).map(\.name) + try expect(tie == ["a-model", "b-model"], "ties break by name: \(tie)") + try expect(CompactPopoverModel.topModels(DailyUsage(date: "d", tools: [:], totalTokens: 0, cost: 0)).isEmpty, "no models, no rows") + } + + // MARK: Rank + + private static func checkChaseNextRank() throws { + let board = leaderboard(ranked: 88, limit: 100, [ + (1, 11, "A", 860_000_000), (2, 12, "B", 716_000_000), (3, 13, "C", 666_000_000), + (34, 20, "Next", 32_630_000), (35, 99, "Me", 28_510_000), (36, 21, "Below", 20_000_000) + ]) + let section = CompactPopoverModel.rankSection( + board: board, myUserID: 99, localTodayTokens: 28_800_000, + tokensPerHour: 9_800_000, localTimeZoneIdentifier: "America/Los_Angeles" + ) + try expect(section.myRank == 35 && section.myBoardTokens == 28_510_000, "own entry found") + guard case let .next(rank, name, gap, progress) = section.chase else { + throw FixtureError("expected a next-rank chase, got \(String(describing: section.chase))") + } + try expect(rank == 34 && name == "Next" && gap == 4_120_001, "gap to #34 is \(gap)") + try expect(abs(progress - 28_510_000.0 / 32_630_000) < 1e-9, "progress toward #34") + try expect(section.etaMinutes == 26, "ETA rounds up: \(String(describing: section.etaMinutes))") + try expect(section.podium.map(\.leadOverMe) == [831_490_000, 687_490_000, 637_490_000], "podium leads over me") + try expect(section.unsyncedTokens == nil && section.projectedRank == nil, "different days are not compared") + } + + private static func checkLeader() throws { + let board = leaderboard(ranked: 3, limit: 100, [(1, 99, "Me", 900), (2, 12, "B", 600), (3, 13, "C", 100)]) + let section = CompactPopoverModel.rankSection( + board: board, myUserID: 99, localTodayTokens: 900, tokensPerHour: 100, localTimeZoneIdentifier: "Asia/Shanghai" + ) + try expect(section.chase == .leading(lead: 300), "first place shows its lead") + try expect(section.etaMinutes == nil, "no ETA when leading") + try expect(section.podium.first?.isMe == true && section.podium.first?.leadOverMe == nil, "podium marks me") + } + + private static func checkOutsideBoard() throws { + let rows = (1...100).map { (rank: $0, id: $0, name: "U\($0)", tokens: 1_000_000_000 / $0) } + let board = leaderboard(ranked: 146, limit: 100, rows) + let shanghai = CompactPopoverModel.rankSection( + board: board, myUserID: 999, localTodayTokens: 3_100_000, tokensPerHour: 1_500_000, localTimeZoneIdentifier: "Asia/Shanghai" + ) + try expect(shanghai.myRank == nil, "not listed") + try expect(shanghai.chase == .enterBoard(rank: 100, tokens: 10_000_000, gap: 6_900_001), "gap to enter the board: \(String(describing: shanghai.chase))") + try expect(shanghai.etaMinutes == 277, "ETA to enter the board: \(String(describing: shanghai.etaMinutes))") + let elsewhere = CompactPopoverModel.rankSection( + board: board, myUserID: 999, localTodayTokens: 3_100_000, tokensPerHour: 1_500_000, localTimeZoneIdentifier: "Europe/Berlin" + ) + try expect(elsewhere.chase == .enterBoard(rank: 100, tokens: 10_000_000, gap: nil), "no gap from a different day") + try expect(elsewhere.etaMinutes == nil, "no ETA without a gap") + let small = leaderboard(ranked: 3, limit: 100, [(1, 1, "A", 3), (2, 2, "B", 2), (3, 3, "C", 1)]) + let absent = CompactPopoverModel.rankSection( + board: small, myUserID: 999, localTodayTokens: 0, tokensPerHour: 0, localTimeZoneIdentifier: "Asia/Shanghai" + ) + try expect(absent.chase == nil, "not on a board that lists everyone: no chase") + } + + private static func checkUnsyncedOnlyOnSameDay() throws { + let board = leaderboard(ranked: 5, limit: 100, [ + (1, 1, "A", 500), (2, 2, "B", 400), (3, 3, "C", 300), (4, 99, "Me", 200), (5, 5, "E", 100) + ]) + let section = CompactPopoverModel.rankSection( + board: board, myUserID: 99, localTodayTokens: 350, tokensPerHour: 0, localTimeZoneIdentifier: "Asia/Shanghai" + ) + try expect(section.unsyncedTokens == 150, "unsynced local tokens") + try expect(section.projectedRank == 3, "350 would place third: \(String(describing: section.projectedRank))") + let behind = CompactPopoverModel.rankSection( + board: board, myUserID: 99, localTodayTokens: 180, tokensPerHour: 0, localTimeZoneIdentifier: "Asia/Shanghai" + ) + try expect(behind.unsyncedTokens == nil && behind.projectedRank == nil, "local behind the board shows nothing") + } + + private static func checkLastHourRate() throws { + let buckets = [HourlyTokenBucket(hour: 11, tokens: 6_000_000), HourlyTokenBucket(hour: 12, tokens: 4_000_000)] + // 12:57 Shanghai: all of 12:00-12:57 plus the last 3 minutes of 11:00. + let rate = CompactPopoverModel.tokensInLastHour(buckets: buckets, now: now, calendar: calendar) + try expect(rate == 4_000_000 + 300_000, "last-hour rate \(rate)") + } + + // MARK: Helpers + + private static func hours(_ value: Double) -> TimeInterval { value * 3600 } + + private static func row(_ kind: QuotaWindowKind, used: Double, resetsIn: TimeInterval?) -> CompactPopoverModel.QuotaWindowRow { + CompactPopoverModel.windowRow( + QuotaWindow(kind: kind, usedPercent: used, resetsAt: resetsIn.map { now.addingTimeInterval($0) }), + now: now, + calendar: calendar + ) + } + + private static func quota(_ provider: QuotaProviderID, _ windows: [(QuotaWindowKind, Double, TimeInterval?)]) -> ProviderQuota { + ProviderQuota( + provider: provider, + windows: windows.map { QuotaWindow(kind: $0.0, usedPercent: $0.1, resetsAt: $0.2.map { now.addingTimeInterval($0) }) }, + status: .available, + fetchedAt: now, + message: nil + ) + } + + private static func leaderboard(ranked: Int, limit: Int, _ rows: [(rank: Int, id: Int, name: String, tokens: Int)]) -> TokenRankLeaderboard { + TokenRankLeaderboard( + fetchedAt: now, + range: "today", + client: "all", + usageMode: "all", + totalTokens: rows.map(\.tokens).reduce(0, +), + totalRankedUsers: ranked, + topLimit: limit, + entries: rows.map { + TokenRankEntry(rank: $0.rank, userID: $0.id, name: $0.name, avatarURL: nil, totalTokens: $0.tokens, callCount: 0, sessionCount: 0, clients: [:], models: [:]) + } + ) + } + + static func expect(_ condition: Bool, _ message: String) throws { + if !condition { throw FixtureError(message) } + } +} + +private struct FixtureError: Error, CustomStringConvertible { + var description: String + init(_ description: String) { self.description = description } +} diff --git a/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift b/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift index 24ed94b..0049f7f 100644 --- a/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift +++ b/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift @@ -29,7 +29,7 @@ struct PopoverPanelRender { let showsRank = ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_RANK"] != "hidden" let showsQuotas = ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_QUOTAS"] != "hidden" let updateState = ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_UPDATE_STATE"] ?? "idle" - let rank = fixtureRank() + let rank = ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_RANK"] == "chase" ? chaseRank() : fixtureRank() let update = fixtureUpdate() let appState = AppState() appState.installRenderFixture( @@ -41,7 +41,9 @@ struct PopoverPanelRender { showsQuotas: showsQuotas, language: language ), - quotas: showsQuotas ? fixtureQuotas() : [:], + quotas: showsQuotas + ? (ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_QUOTAS"] == "many" ? manyQuotas() : fixtureQuotas()) + : [:], tokenRank: showsRank ? rank.leaderboard : nil, agentWorkRankIdentity: showsRank ? rank.identity : nil, updateCheckPhase: fixtureUpdatePhase(updateState, update: update), @@ -73,7 +75,7 @@ struct PopoverPanelRender { throw NSError(domain: "PopoverPanelRender", code: 1) } try png.write(to: output, options: .atomic) - guard bitmap.pixelsWide == 1_800, bitmap.pixelsHigh >= 600 else { + guard bitmap.pixelsWide == Int(CompactPopoverStyle.width * 2), bitmap.pixelsHigh >= 300 else { throw NSError(domain: "PopoverPanelRender", code: 2, userInfo: [ NSLocalizedDescriptionKey: "Unexpected render size: \(bitmap.pixelsWide)x\(bitmap.pixelsHigh)" ]) @@ -230,7 +232,7 @@ struct PopoverPanelRender { requireVerifiedUpdates: true, tokenIslandEnabled: false, tokenIslandPlacement: .menuBar, - enabledQuotaProviders: showsQuotas ? [.codex, .claude, .cursor] : [], + enabledQuotaProviders: showsQuotas ? Set(QuotaProviderID.allCases) : [], cursorQuotaEnabled: showsQuotas, cursorCodeSignalEnabled: false, agentWorkRankVisibility: showsRank ? .visible : .hidden, @@ -275,6 +277,52 @@ struct PopoverPanelRender { ) } + /// Mid-board: the viewer at #35 chasing #34, with the podium ahead. + private static func chaseRank() -> (leaderboard: TokenRankLeaderboard, identity: AgentWorkRankIdentity) { + let identity = AgentWorkRankIdentity(id: 42, name: "Agent 黄叔", avatarURL: nil, lastSyncedAt: Date()) + let rows: [(Int, Int, String, Int)] = [ + (1, 1, "十七°", 860_255_773), (2, 2, "开水庆余年", 716_395_709), (3, 3, "柳间君", 666_412_269), + (34, 34, "ancson", 32_630_000), (35, 42, identity.name, 28_510_000), (36, 36, "Susan", 20_122_373) + ] + return ( + TokenRankLeaderboard( + fetchedAt: Date().addingTimeInterval(-2 * 60), + range: "today", + client: "all", + usageMode: "all", + totalTokens: 8_193_118_395, + totalRankedUsers: 88, + topLimit: 100, + entries: rows.map { + TokenRankEntry(rank: $0.0, userID: $0.1, name: $0.2, avatarURL: nil, totalTokens: $0.3, callCount: 0, sessionCount: 0, clients: [:], models: [:]) + } + ), + identity + ) + } + + /// Six providers: one critical, one running out early, the rest calm. + private static func manyQuotas() -> [QuotaProviderID: ProviderQuota] { + let now = Date() + func quota(_ provider: QuotaProviderID, _ windows: [QuotaWindow]) -> ProviderQuota { + ProviderQuota(provider: provider, windows: windows, status: .available, fetchedAt: now.addingTimeInterval(-7 * 60), message: nil) + } + return [ + .codex: quota(.codex, [ + QuotaWindow(kind: .fiveHour, usedPercent: 96, resetsAt: now.addingTimeInterval(2 * 3600)), + QuotaWindow(kind: .sevenDay, usedPercent: 89, resetsAt: now.addingTimeInterval(34.5 * 3600)) + ]), + .claude: quota(.claude, [ + QuotaWindow(kind: .fiveHour, usedPercent: 58, resetsAt: now.addingTimeInterval(3 * 3600)), + QuotaWindow(kind: .sevenDay, usedPercent: 71, resetsAt: now.addingTimeInterval(96 * 3600)) + ]), + .grok: quota(.grok, [QuotaWindow(kind: .monthlyCredits, usedPercent: 32, resetsAt: now.addingTimeInterval(9 * 86_400))]), + .glm: quota(.glm, [QuotaWindow(kind: .monthlyCredits, usedPercent: 24, resetsAt: now.addingTimeInterval(6 * 86_400))]), + .kimi: quota(.kimi, [QuotaWindow(kind: .weekly, usedPercent: 12, resetsAt: now.addingTimeInterval(5 * 86_400))]), + .cursor: quota(.cursor, [QuotaWindow(kind: .cursorModels, usedPercent: 45, resetsAt: now.addingTimeInterval(6 * 86_400))]) + ] + } + private static func fixtureUpdatePhase( _ state: String, update: AvailableUpdate diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/PopoverModelUsageRowsTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/PopoverModelUsageRowsTests.swift deleted file mode 100644 index 43fbeec..0000000 --- a/TokenStepSwift/Tests/TokenStepSwiftTests/PopoverModelUsageRowsTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import XCTest -@testable import TokenStepSwift - -final class PopoverModelUsageRowsTests: XCTestCase { - func testOneToThreeModelsRemainIndividualAndSorted() throws { - let usage = makeUsage( - models: ["small": 100, "large": 600, "medium": 300], - totalTokens: 1_200 - ) - - let rows = try rows(from: usage) - - XCTAssertEqual(rows.map(\.kind), [.model("large"), .model("medium"), .model("small")]) - XCTAssertEqual(rows.map(\.tokens), [600, 300, 100]) - XCTAssertEqual(rows.map(\.percent), [50, 25, 100.0 / 12.0]) - } - - func testMoreThanThreeModelsUseTopThreeAndAggregateEveryRemainingToken() throws { - let usage = makeUsage( - models: ["a": 500, "b": 300, "c": 100, "d": 60, "e": 30, "f": 10], - totalTokens: 1_000 - ) - - let rows = try rows(from: usage) - - XCTAssertEqual(rows.map(\.kind), [.model("a"), .model("b"), .model("c"), .other]) - XCTAssertEqual(rows.map(\.tokens), [500, 300, 100, 100]) - XCTAssertEqual(rows.last?.percent, 10) - XCTAssertEqual(rows.count, PopoverModelUsageRows.maximumVisibleRows) - } - - func testOtherIncludesNegligibleRowsHiddenByDashboard() throws { - let usage = makeUsage( - models: ["primary": 9_990, "b": 2, "c": 2, "d": 2, "e": 2, "f": 2], - totalTokens: 10_000 - ) - - XCTAssertLessThan(TodayModelUsageRows.make(from: usage).count, usage.models.count) - let rows = try rows(from: usage) - XCTAssertEqual(rows.map(\.kind), [.model("primary"), .model("b"), .model("c"), .other]) - XCTAssertEqual(rows.last?.tokens, 6) - XCTAssertEqual(rows.reduce(0) { $0 + $1.tokens }, 10_000) - } - - func testZeroTotalHidesSectionAndMissingDetailsWait() { - XCTAssertEqual( - PopoverModelUsageRows.state(from: makeUsage(models: ["gpt": 10], totalTokens: 0)), - .hidden - ) - XCTAssertEqual( - PopoverModelUsageRows.state(from: makeUsage(models: [:], totalTokens: 100)), - .waiting - ) - } - - func testRealOtherModelDoesNotCollideWithAggregateIdentity() throws { - let usage = makeUsage( - models: ["other": 400, "a": 300, "b": 200, "c": 80, "d": 20], - totalTokens: 1_000 - ) - let rows = try rows(from: usage) - XCTAssertEqual(Set(rows.map(\.id)).count, rows.count) - XCTAssertTrue(rows.contains { $0.kind == .model("other") }) - XCTAssertTrue(rows.contains { $0.kind == .other }) - } - - func testMismatchFlagAndPercentClampArePreserved() throws { - let usage = makeUsage(models: ["unexpected": 150], totalTokens: 100) - guard case let .rows(rows, mismatch) = PopoverModelUsageRows.state(from: usage) else { - return XCTFail("Expected rows") - } - XCTAssertTrue(mismatch) - XCTAssertEqual(rows.first?.percent, 100) - } - - private func rows(from usage: DailyUsage) throws -> [PopoverModelUsageRow] { - guard case let .rows(rows, _) = PopoverModelUsageRows.state(from: usage) else { - throw NSError(domain: "PopoverModelUsageRowsTests", code: 1) - } - return rows - } - - private func makeUsage(models: [String: Int], totalTokens: Int) -> DailyUsage { - DailyUsage( - date: "2026-08-25", - tools: totalTokens > 0 ? ["Codex": totalTokens] : [:], - models: models, - modelCosts: [:], - totalTokens: totalTokens, - cost: 0 - ) - } -} diff --git a/script/test_all.sh b/script/test_all.sh index e6c4de1..52d64d8 100755 --- a/script/test_all.sh +++ b/script/test_all.sh @@ -18,6 +18,7 @@ FIXTURE_CHECKS=( test_time_zone_collector test_claude_incremental_collector test_settings_codable + test_compact_popover ) failed=() diff --git a/script/test_compact_popover.sh b/script/test_compact_popover.sh new file mode 100755 index 0000000..608cfc9 --- /dev/null +++ b/script/test_compact_popover.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Checks the compact popover's content rules: quota ordering and folding, pace +# projection, top models, and the rank chase. + +# Fixture expectations are written in this zone. +export TOKENSTEP_TIMEZONE="Asia/Shanghai" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SWIFT_DIR="$ROOT_DIR/TokenStepSwift" +BUILD_DIR="/tmp/tokenstep-compact-popover-fixture-$UID-$$" +OVERLAY_DIR="$BUILD_DIR/vfs-overlay" +OVERLAY_FILE="$OVERLAY_DIR/overlay.yaml" +EMPTY_MODULEMAP="$OVERLAY_DIR/empty.modulemap" +EXECUTABLE="$BUILD_DIR/compact-popover-fixture-check" + +cleanup() { + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +mkdir -p "$BUILD_DIR" "$OVERLAY_DIR" +cat > "$EMPTY_MODULEMAP" <<'EOF' +// Intentionally empty. +EOF +cat > "$OVERLAY_FILE" < Date: Fri, 25 Sep 2026 21:02:26 -0700 Subject: [PATCH 2/5] feat(island): open the compact popover from the notch island The notch island still opened its own older card (big ring, two tool rows, three buttons), so on notched Macs the new single column never showed. The island card now hosts PopoverPanelView, so both entry points show the same content. The card window follows the content height with its top edge fixed, since quota rows and the rank section come and go. While a menu is tracking (the "..." menu opens outside the card) leaving the card no longer collapses it. Co-Authored-By: Claude Opus 5.5 --- .../Support/TokenIslandWindowPresenter.swift | 73 +++- .../Views/TokenIslandView.swift | 329 ++---------------- 2 files changed, 97 insertions(+), 305 deletions(-) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift index 13e67d4..0368a4c 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift @@ -7,7 +7,6 @@ final class TokenIslandWindowPresenter { static let shared = TokenIslandWindowPresenter() static let collapsedSize = NSSize(width: 88, height: 24) - static let expandedSize = TokenIslandMetrics.expandedWindowSize private weak var appState: AppState? private var ringPanel: TokenIslandPanel? @@ -16,6 +15,11 @@ final class TokenIslandWindowPresenter { private var screenObserver: NSObjectProtocol? private var hidePopoverTask: DispatchWorkItem? private var popoverVisible = false + private var popoverCardHeight = TokenIslandMetrics.initialCardHeight + /// The popover's "..." menu opens outside the card. While any menu is + /// tracking, leaving the card must not collapse it. + private var menuTracking = false + private var menuObservers: [NSObjectProtocol] = [] func bind(appState: AppState) { guard self.appState !== appState else { @@ -43,9 +47,39 @@ final class TokenIslandWindowPresenter { } } + observeMenuTracking() syncVisibility() } + private func observeMenuTracking() { + guard menuObservers.isEmpty else { return } + let center = NotificationCenter.default + menuObservers = [ + center.addObserver(forName: NSMenu.didBeginTrackingNotification, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in + self?.menuTracking = true + self?.cancelHidePopover() + } + }, + center.addObserver(forName: NSMenu.didEndTrackingNotification, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.menuTracking = false + if self.popoverVisible, !self.isMouseInsidePopover { + self.scheduleHidePopover() + } + } + } + ] + } + + private var isMouseInsidePopover: Bool { + guard let panel = popoverPanel else { return false } + let card = panel.frame.insetBy(dx: TokenIslandMetrics.expandedShadowMargin, dy: TokenIslandMetrics.expandedShadowMargin) + let mouse = NSEvent.mouseLocation + return card.contains(mouse) || (ringPanel?.frame.contains(mouse) ?? false) + } + func showPopover() { hidePopoverTask?.cancel() hidePopoverTask = nil @@ -78,6 +112,7 @@ final class TokenIslandWindowPresenter { func scheduleHidePopover() { hidePopoverTask?.cancel() + guard !menuTracking else { return } let task = DispatchWorkItem { [weak self] in self?.hidePopover() } @@ -156,19 +191,24 @@ final class TokenIslandWindowPresenter { } private func makePopoverPanel(appState: AppState) -> TokenIslandPanel { - let rootView = TokenIslandPopoverWindowView { [weak self] hovering in - if hovering { - self?.cancelHidePopover() - } else { - self?.scheduleHidePopover() + let rootView = TokenIslandPopoverWindowView( + onHoverChanged: { [weak self] hovering in + if hovering { + self?.cancelHidePopover() + } else { + self?.scheduleHidePopover() + } + }, + onHeightChanged: { [weak self] height in + self?.updatePopoverCardHeight(height) } - } + ) .environmentObject(appState) return makePanel( title: "TokenStep Island Popover", identifier: "token-island-popover", - size: Self.expandedSize, + size: TokenIslandMetrics.expandedWindowSize(cardHeight: popoverCardHeight), rootView: rootView, hasShadow: false ) @@ -183,6 +223,9 @@ final class TokenIslandWindowPresenter { ) -> TokenIslandPanel { let controller = NSHostingController(rootView: rootView) + // The presenter owns every frame; the hosting view must not resize + // the panel on its own. + controller.sizingOptions = [] controller.view.wantsLayer = true controller.view.layer?.backgroundColor = NSColor.clear.cgColor controller.view.layer?.masksToBounds = false @@ -224,10 +267,20 @@ final class TokenIslandWindowPresenter { panel.setFrame(frame, display: true, animate: false) } + private func updatePopoverCardHeight(_ height: CGFloat) { + guard height > 0, abs(height - popoverCardHeight) > 0.5 else { return } + popoverCardHeight = height + guard popoverVisible, + let panel = popoverPanel, + let screen = TokenIslandDisplayDetector.notchedPrimaryScreen + else { return } + positionPopoverPanel(panel, on: screen) + } + private func positionPopoverPanel(_ panel: TokenIslandPanel, on screen: NSScreen) { let margin = TokenIslandMetrics.expandedShadowMargin - let cardSize = TokenIslandMetrics.expandedCardSize - let size = Self.expandedSize + let size = TokenIslandMetrics.expandedWindowSize(cardHeight: popoverCardHeight) + let cardSize = NSSize(width: TokenIslandMetrics.expandedCardWidth, height: popoverCardHeight) let ringFrame = ringPanel?.frame ?? TokenIslandDisplayDetector.collapsedFrame( on: screen, diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift index 9249cd8..c0243a1 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift @@ -2,14 +2,18 @@ import AppKit import SwiftUI enum TokenIslandMetrics { - static let expandedCardSize = NSSize(width: 392, height: 326) - static let expandedCornerRadius: CGFloat = 28 + /// The expanded card shows the same content as the menu bar popover, so it + /// shares its width; the height follows the content. + static let expandedCardWidth = CompactPopoverStyle.width + static let expandedCornerRadius: CGFloat = 16 static let expandedShadowMargin: CGFloat = 26 + /// Used before the card reports its measured height. + static let initialCardHeight: CGFloat = 420 - static var expandedWindowSize: NSSize { + static func expandedWindowSize(cardHeight: CGFloat) -> NSSize { NSSize( - width: expandedCardSize.width + expandedShadowMargin * 2, - height: expandedCardSize.height + expandedShadowMargin * 2 + width: expandedCardWidth + expandedShadowMargin * 2, + height: cardHeight + expandedShadowMargin * 2 ) } } @@ -39,28 +43,42 @@ struct TokenIslandWindowView: View { } } +/// The card that opens from the island. It hosts the menu bar popover so both +/// entry points show the same compact column. struct TokenIslandPopoverWindowView: View { @EnvironmentObject private var appState: AppState var onHoverChanged: (Bool) -> Void + var onHeightChanged: (CGFloat) -> Void = { _ in } + + private var shape: RoundedRectangle { + RoundedRectangle(cornerRadius: TokenIslandMetrics.expandedCornerRadius, style: .continuous) + } var body: some View { - TokenIslandExpandedSurface { - TokenIslandExpandedView() - .environmentObject(appState) - } - .padding(TokenIslandMetrics.expandedShadowMargin) - .frame( - width: TokenIslandMetrics.expandedWindowSize.width, - height: TokenIslandMetrics.expandedWindowSize.height - ) + PopoverPanelView() + .environmentObject(appState) + .fixedSize(horizontal: false, vertical: true) + .clipShape(shape) + .overlay(shape.stroke(Color.tokenHairlineStrong)) + .contentShape(shape) + .shadow(color: Color.tokenShadow, radius: 22, x: 0, y: 12) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { onHeightChanged(proxy.size.height) } + .onChange(of: proxy.size.height) { _, height in + onHeightChanged(height) + } + } + } .onHover { hovering in onHoverChanged(hovering) } - .transition(.opacity.combined(with: .scale(scale: 0.94, anchor: .topLeading))) + .padding(TokenIslandMetrics.expandedShadowMargin) + .frame(maxHeight: .infinity, alignment: .top) .environment(\.colorScheme, appState.settings.theme.colorScheme) .id(appState.appearanceID) - .animation(.spring(response: 0.26, dampingFraction: 0.88), value: appState.appearanceID) } } @@ -121,282 +139,3 @@ struct TokenIslandRingView: View { .id("\(theme.id)-\(language.resolved.id)") } } - -private struct TokenIslandExpandedView: View { - @EnvironmentObject private var appState: AppState - - private var lap: TokenStepLapProgress { appState.todayLap } - - var body: some View { - VStack(alignment: .leading, spacing: 13) { - header - - HStack(alignment: .center, spacing: 16) { - ring - VStack(alignment: .leading, spacing: 7) { - Text(lap.lapStatusText) - .font(.system(size: 20, weight: .heavy, design: .rounded)) - .foregroundStyle(lap.ringColor) - .monospacedDigit() - .lineLimit(1) - Text(TokenStepFormat.tokens(appState.today.totalTokens)) - .font(.system(size: 27, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .monospacedDigit() - .lineLimit(1) - Text(lap.perLapGoalText) - .font(.caption.weight(.bold)) - .foregroundStyle(Color.tokenInk.opacity(0.55)) - .lineLimit(1) - } - Spacer(minLength: 0) - } - - TokenIslandToolSplitView(tools: appState.today.tools, total: appState.today.totalTokens) - - if appState.settings.showCodexQuota, appState.hasAnyQuota { - TokenIslandQuotaMiniView(quotas: appState.visibleQuotas.filter(\.isAvailable)) - } - - HStack(spacing: 8) { - TokenIslandActionButton(title: L("打开仪表盘"), symbol: "arrow.up.right") { - MainWindowPresenter.shared.show(appState: appState) - } - TokenIslandActionButton(title: L("刷新"), symbol: "arrow.clockwise") { - appState.refresh() - } - .disabled(appState.isRefreshing) - TokenIslandActionButton(title: L("设置"), symbol: "gearshape") { - SettingsWindowPresenter.shared.show(appState: appState) - } - } - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - } - - private var header: some View { - HStack(spacing: 8) { - TokenStepBrandLockup(markSize: 28, titleSize: 12) - Spacer() - HStack(spacing: 5) { - Circle() - .fill(appState.isRefreshing ? Color.secondary.opacity(0.66) : Color.tokenSuccess) - .frame(width: 6, height: 6) - Text(appState.isRefreshing ? L("同步中") : L("已同步")) - .font(.caption2.weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.70)) - } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background(Color.tokenSurface, in: Capsule()) - .overlay(Capsule().stroke(Color.tokenDivider)) - } - } - - private var ring: some View { - ZStack { - if TokenStepThemeRuntime.isVoyage { - VoyageBowProgressView(progress: lap.currentLapProgress, lineWidth: 9, color: lap.ringColor) - } else if TokenStepThemeRuntime.isInterstellar { - ProgressRingView(progress: lap.currentLapProgress, lineWidth: 9, color: lap.ringColor) - .overlay { - InterstellarEventHorizonEmblem() - .padding(18) - .opacity(0.48) - } - } else { - ProgressRingView(progress: lap.currentLapProgress, lineWidth: 9, color: lap.ringColor) - } - VStack(spacing: 2) { - Text(TokenStepFormat.tokens(appState.today.totalTokens, compact: true)) - .font(.system(size: 17, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .minimumScaleFactor(0.62) - .lineLimit(1) - Text(lap.lapTitle) - .font(.caption2.weight(.bold)) - .foregroundStyle(Color.tokenInk.opacity(0.48)) - } - .frame(width: 66) - } - .frame(width: 86, height: 86) - } -} - -private struct TokenIslandExpandedSurface: View { - var content: Content - - private var shape: RoundedRectangle { - RoundedRectangle(cornerRadius: TokenIslandMetrics.expandedCornerRadius, style: .continuous) - } - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - content - .frame( - width: TokenIslandMetrics.expandedCardSize.width, - height: TokenIslandMetrics.expandedCardSize.height - ) - .background(TokenIslandExpandedBackground().clipShape(shape)) - .clipShape(shape) - .overlay { - ZStack { - shape.stroke(Color.tokenHairlineStrong) - if TokenStepThemeRuntime.isVoyage { - VoyageCardOrnament(cornerRadius: TokenIslandMetrics.expandedCornerRadius) - } else if TokenStepThemeRuntime.isInterstellar { - InterstellarCardOrnament(cornerRadius: TokenIslandMetrics.expandedCornerRadius) - } - } - } - .contentShape(shape) - .shadow(color: Color.tokenShadow, radius: 22, x: 0, y: 12) - } -} - -private struct TokenIslandExpandedBackground: View { - var body: some View { - ZStack { - Color.tokenSurface - LinearGradient( - colors: [ - Color.tokenGreen.opacity(0.11), - Color.tokenCanvas.opacity(0.35), - Color.tokenGreenDark.opacity(0.045) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - if TokenStepThemeRuntime.isVoyage { - VoyageBackdropAtmosphere(role: .island) - } else if TokenStepThemeRuntime.isInterstellar { - InterstellarBackdrop(role: .island) - } - } - } -} - -private struct TokenIslandToolSplitView: View { - var tools: [String: Int] - var total: Int - - var body: some View { - VStack(spacing: 7) { - TokenIslandSplitRow(name: "Codex", tokens: tools["Codex"] ?? 0, total: total) - TokenIslandSplitRow(name: "Claude Code", tokens: tools["Claude Code"] ?? 0, total: total) - } - .padding(.horizontal, 10) - .padding(.vertical, 9) - .background(Color.tokenTrack.opacity(0.42), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - } -} - -private struct TokenIslandSplitRow: View { - var name: String - var tokens: Int - var total: Int - - private var percent: Double { - guard total > 0 else { return 0 } - return min(max(Double(tokens) / Double(total), 0), 1) - } - - var body: some View { - HStack(spacing: 8) { - Text(name) - .font(.caption2.weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.70)) - .frame(width: 70, alignment: .leading) - .lineLimit(1) - - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule().fill(Color.tokenTrack.opacity(0.84)) - Capsule() - .fill(Color.tokenGreen) - .frame(width: max(tokens > 0 ? 4 : 0, proxy.size.width * percent)) - } - } - .frame(height: 5) - - Text(TokenStepFormat.tokens(tokens, compact: true)) - .font(.caption2.weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.78)) - .monospacedDigit() - .frame(width: 48, alignment: .trailing) - } - } -} - -private struct TokenIslandQuotaMiniView: View { - var quotas: [ProviderQuota] - - var body: some View { - HStack(spacing: 10) { - Image(systemName: "hourglass.circle.fill") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(Color.tokenGreen) - ForEach(Array(quotas.prefix(3).enumerated()), id: \.element.id) { index, quota in - if index > 0 { - Divider().frame(height: 15).overlay(Color.tokenDivider) - } - quotaBlock(quota) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(Color.tokenTrack.opacity(0.38), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - } - - private func quotaBlock(_ quota: ProviderQuota) -> some View { - VStack(alignment: .leading, spacing: 3) { - Text(quota.provider.displayName) - .font(.system(size: 9, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk.opacity(0.48)) - .lineLimit(1) - HStack(spacing: 6) { - ForEach(quota.windows.prefix(2)) { window in - HStack(spacing: 3) { - Text(window.kind.shortTitle) - .font(.system(size: 9, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk.opacity(0.50)) - Text(TokenStepFormat.percent(window.remainingPercent)) - .font(.system(size: 9, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenGreen) - .monospacedDigit() - } - } - } - } - .lineLimit(1) - .minimumScaleFactor(0.78) - } -} - -private struct TokenIslandActionButton: View { - var title: String - var symbol: String - var action: () -> Void - - var body: some View { - Button(action: action) { - Label(title, systemImage: symbol) - .font(.caption2.weight(.heavy)) - .labelStyle(.titleAndIcon) - .lineLimit(1) - .minimumScaleFactor(0.82) - .foregroundStyle(Color.tokenInk.opacity(0.82)) - .frame(maxWidth: .infinity) - .frame(height: 28) - .background(Color.tokenSurface, in: Capsule()) - .overlay(Capsule().stroke(Color.tokenHairline)) - } - .buttonStyle(.plain) - .help(title) - } -} From c8d41d8293ada577ce978098fc6baea93f42b0de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AI=E4=BA=A7=E5=93=81=E9=BB=84=E5=8F=94?= Date: Fri, 25 Sep 2026 21:02:26 -0700 Subject: [PATCH 3/5] feat(popover): Claude mark, clearer rank and quota rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Claude quota shows the Claude symbol when the desktop app is not installed (CLI-only users), drawn from its SVG path in ProviderMark so no resource file is needed. Installed app icons still win. - Not on today's board shows "未上榜" instead of a bare dash, which read as a thick bar. A short board lists everyone, so the caption says no upload today; a full board still says "not in the top N". - The chase headline ("再跑 …") drops from 18pt heavy to 13pt bold so it no longer outweighs the rank. - Quota rows explain the tick on hover (time passed vs used), and the row that runs out early tints its title to match the warning badge. Co-Authored-By: Claude Opus 5.5 --- .../TokenStepSwift/Support/Localization.swift | 10 ++ .../Popover/CompactPopoverSections.swift | 70 ++++++-- .../Views/Popover/ProviderMark.swift | 155 ++++++++++++++++++ 3 files changed, 220 insertions(+), 15 deletions(-) create mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Popover/ProviderMark.swift diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index 53e5484..fa83236 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -509,11 +509,16 @@ enum TokenStepLocalization { "更多:分享、截图、退出": "More: share, screenshot, quit", "最近 1 小时没有用量": "No usage in the last hour", "未进入前 %d 名": "Not in the top %d", + "未上榜": "Not ranked", + "今日还没有你的上传": "No uploads from you today yet", "本地比榜单多 %@,同步后预计升到 #%d": "%@ more locally than on the board; about #%d after sync", "本地比榜单多 %@,等待同步": "%@ more locally than on the board, waiting for sync", "榜上 %@": "On board %@", "榜单 %@": "Board %@", "照此速度会提前用完": "Runs out early at this pace", + "已用 %@": "%@ used", + "竖线是时间进度:已过 %@,已用 %@": "The tick marks time passed: %@ of the window gone, %@ used", + "。用得比时间快,照此速度会在重置前用完": ". Usage is ahead of time and runs out before the reset at this pace", "第 %d 名今日 %@": "#%d today: %@", "触发坠落脉冲": "Trigger plunge pulse", "超过 #%d %@": "to pass #%d %@", @@ -1199,11 +1204,16 @@ enum TokenStepLocalization { "更多:分享、截图、退出": "更多:分享、截圖、退出", "最近 1 小时没有用量": "最近 1 小時沒有用量", "未进入前 %d 名": "未進入前 %d 名", + "未上榜": "未上榜", + "今日还没有你的上传": "今日還沒有你的上傳", "本地比榜单多 %@,同步后预计升到 #%d": "本機比榜單多 %@,同步後預計升到 #%d", "本地比榜单多 %@,等待同步": "本機比榜單多 %@,等待同步", "榜上 %@": "榜上 %@", "榜单 %@": "榜單 %@", "照此速度会提前用完": "照此速度會提前用完", + "已用 %@": "已用 %@", + "竖线是时间进度:已过 %@,已用 %@": "豎線是時間進度:已過 %@,已用 %@", + "。用得比时间快,照此速度会在重置前用完": "。用得比時間快,照此速度會在重置前用完", "第 %d 名今日 %@": "第 %d 名今日 %@", "触发坠落脉冲": "觸發墜落脈衝", "超过 #%d %@": "超過 #%d %@", diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift index fc4910f..847f89c 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift @@ -3,9 +3,8 @@ import SwiftUI // MARK: - Provider icon -/// The provider's installed app icon when one is found, otherwise a monogram -/// tile in the provider's color. Icons come from the user's own apps at -/// runtime; none are bundled. +/// The provider's installed app icon when one is found, then a bundled mark +/// (see `ProviderMark`), otherwise a monogram tile in the provider's color. struct QuotaProviderIcon: View { var provider: QuotaProviderID var size: CGFloat = 20 @@ -17,6 +16,22 @@ struct QuotaProviderIcon: View { .interpolation(.high) .frame(width: size, height: size) .accessibilityHidden(true) + } else if provider == .claude { + // Cream tile like Claude's app icon, so the mark sits at the same + // visual weight as the other providers' app icons. + RoundedRectangle(cornerRadius: size * 0.25, style: .continuous) + .fill(Color(red: 244 / 255, green: 243 / 255, blue: 238 / 255)) + .frame(width: size, height: size) + .overlay { + SVGPathShape(pathData: ProviderMark.claudePath) + .fill(ProviderMark.claudeColor) + .padding(size * 0.12) + } + .overlay { + RoundedRectangle(cornerRadius: size * 0.25, style: .continuous) + .stroke(Color.black.opacity(0.08), lineWidth: 0.5) + } + .accessibilityHidden(true) } else { RoundedRectangle(cornerRadius: size * 0.25, style: .continuous) .fill(AgentSourceRegistry.color(for: provider.rawValue)) @@ -240,6 +255,19 @@ struct CompactQuotaSection: View { ) } + /// Explains the bar's tick: how much of the window's time has passed. + private func paceHelp(_ window: CompactPopoverModel.QuotaWindowRow) -> String { + guard let elapsed = window.elapsedFraction else { + return LFormat("已用 %@", CompactPopoverStyle.percent(window.usedPercent)) + } + let text = LFormat( + "竖线是时间进度:已过 %@,已用 %@", + CompactPopoverStyle.percent(elapsed * 100), + CompactPopoverStyle.percent(window.usedPercent) + ) + return window.runsOutEarly ? text + L("。用得比时间快,照此速度会在重置前用完") : text + } + private func providerBlock(_ provider: CompactPopoverModel.QuotaProviderRow) -> some View { VStack(alignment: .leading, spacing: 7) { HStack(spacing: 8) { @@ -260,8 +288,8 @@ struct CompactQuotaSection: View { ForEach(provider.windows) { window in HStack(spacing: 8) { Text(window.title) - .font(.system(size: 11)) - .foregroundStyle(.secondary) + .font(.system(size: 11, weight: window.runsOutEarly ? .semibold : .regular)) + .foregroundStyle(window.runsOutEarly ? CompactPopoverStyle.color(for: .warning) : Color.secondary) .frame(width: 58, alignment: .leading) .lineLimit(1) CompactBar( @@ -280,6 +308,8 @@ struct CompactQuotaSection: View { .lineLimit(1) .frame(width: 72, alignment: .trailing) } + .contentShape(Rectangle()) + .help(paceHelp(window)) .accessibilityElement(children: .combine) } } @@ -354,10 +384,16 @@ struct CompactRankSection: View { VStack(alignment: .leading, spacing: 10) { CompactSectionHeader(title: L("今日排名"), trailing: LFormat("榜单 %@", CompactPopoverStyle.relative(fetchedAt))) HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(section.myRank.map { "#\($0)" } ?? "—") - .font(.system(size: 26, weight: .heavy, design: .rounded)) - .monospacedDigit() - .foregroundStyle(Color.tokenInk) + if let rank = section.myRank { + Text("#\(rank)") + .font(.system(size: 26, weight: .heavy, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color.tokenInk) + } else { + Text(L("未上榜")) + .font(.system(size: 17, weight: .heavy, design: .rounded)) + .foregroundStyle(Color.tokenInk) + } Text(rankCaption) .font(.system(size: 12)) .foregroundStyle(.secondary) @@ -398,8 +434,12 @@ struct CompactRankSection: View { } private var rankCaption: String { - if section.myRank == nil, section.chase != nil { - return LFormat("未进入前 %d 名", section.listedLimit) + if section.myRank == nil { + // A full board may hide ranks past the limit; a short one lists + // everyone, so being absent means nothing uploaded today. + return section.chase != nil + ? LFormat("未进入前 %d 名", section.listedLimit) + : L("今日还没有你的上传") } return LFormat("/ %d 人", section.rankedUsers) } @@ -456,14 +496,14 @@ struct CompactRankSection: View { } private func chaseHeadline(gap: Int, target: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 4) { Text(L("再跑")) - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 12, weight: .medium)) Text(TokenStepFormat.tokens(gap, compact: true)) - .font(.system(size: 18, weight: .heavy, design: .rounded)) + .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() Text(target) - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 12, weight: .medium)) .lineLimit(1) .truncationMode(.tail) } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/ProviderMark.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/ProviderMark.swift new file mode 100644 index 0000000..510bd69 --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/ProviderMark.swift @@ -0,0 +1,155 @@ +import SwiftUI + +/// Provider marks bundled with the app, drawn from their SVG path data so they +/// need no resource files. Used when the provider's desktop app is not +/// installed and so has no icon to borrow. +enum ProviderMark { + /// The Claude symbol, in a 100×100 view box. + static let claudePath = + "m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6." + + "3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3" + + ".9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3" + + "-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1." + + "6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-" + + "1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 " + + "1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-" + + "1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1" + + ".8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1." + + "4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-" + + "12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z" + static let claudeColor = Color(red: 217 / 255, green: 119 / 255, blue: 87 / 255) +} + +/// Fills an SVG path string scaled into the view's frame. Supports the +/// commands M, L, H, V, C, S, Z in absolute and relative form, which covers +/// the bundled marks. +struct SVGPathShape: Shape { + var pathData: String + var viewBox: CGSize = CGSize(width: 100, height: 100) + + func path(in rect: CGRect) -> Path { + let scale = min(rect.width / viewBox.width, rect.height / viewBox.height) + let offset = CGPoint( + x: rect.minX + (rect.width - viewBox.width * scale) / 2, + y: rect.minY + (rect.height - viewBox.height * scale) / 2 + ) + return Self.parse(pathData).applying( + CGAffineTransform(translationX: offset.x, y: offset.y).scaledBy(x: scale, y: scale) + ) + } + + static func parse(_ data: String) -> Path { + var path = Path() + var tokens = SVGPathTokens(data) + var current = CGPoint.zero + var start = CGPoint.zero + var lastControl: CGPoint? + var command: Character = "M" + + while let next = tokens.nextCommand(default: command) { + command = next + let relative = command.isLowercase + let base = relative ? current : .zero + func point() -> CGPoint? { + guard let x = tokens.nextNumber(), let y = tokens.nextNumber() else { return nil } + return CGPoint(x: base.x + x, y: base.y + y) + } + + switch command.uppercased() { + case "M": + guard let p = point() else { return path } + path.move(to: p) + current = p + start = p + // Extra pairs after a move are implicit line-tos. + command = relative ? "l" : "L" + lastControl = nil + case "L": + guard let p = point() else { return path } + path.addLine(to: p) + current = p + lastControl = nil + case "H": + guard let x = tokens.nextNumber() else { return path } + current = CGPoint(x: relative ? current.x + x : x, y: current.y) + path.addLine(to: current) + lastControl = nil + case "V": + guard let y = tokens.nextNumber() else { return path } + current = CGPoint(x: current.x, y: relative ? current.y + y : y) + path.addLine(to: current) + lastControl = nil + case "C": + guard let c1 = point(), let c2 = point(), let p = point() else { return path } + path.addCurve(to: p, control1: c1, control2: c2) + current = p + lastControl = c2 + case "S": + guard let c2 = point(), let p = point() else { return path } + let c1 = lastControl.map { CGPoint(x: 2 * current.x - $0.x, y: 2 * current.y - $0.y) } ?? current + path.addCurve(to: p, control1: c1, control2: c2) + current = p + lastControl = c2 + case "Z": + path.closeSubpath() + current = start + lastControl = nil + default: + return path + } + } + return path + } +} + +private struct SVGPathTokens { + private let scalars: [Character] + private var index = 0 + + init(_ data: String) { + scalars = Array(data) + } + + private mutating func skipSeparators() { + while index < scalars.count, scalars[index] == " " || scalars[index] == "," || scalars[index].isNewline { + index += 1 + } + } + + /// The next command letter, or `defaultCommand` repeated when numbers follow + /// without one. Nil at the end of the data. + mutating func nextCommand(default defaultCommand: Character) -> Character? { + skipSeparators() + guard index < scalars.count else { return nil } + let character = scalars[index] + if character.isLetter { + index += 1 + return character + } + return defaultCommand == "Z" || defaultCommand == "z" ? nil : defaultCommand + } + + mutating func nextNumber() -> CGFloat? { + skipSeparators() + let begin = index + if index < scalars.count, scalars[index] == "-" || scalars[index] == "+" { index += 1 } + var seenDot = false + var seenDigit = false + while index < scalars.count { + let character = scalars[index] + if character.isASCII, character.isNumber { + seenDigit = true + } else if character == ".", !seenDot { + seenDot = true + } else { + break + } + index += 1 + } + guard seenDigit, let value = Double(String(scalars[begin.. Date: Fri, 25 Sep 2026 21:02:26 -0700 Subject: [PATCH 4/5] fix(quota): refresh quotas every 5 minutes while a window is open Quota could be up to about 25 minutes old: the app checked every 15 minutes, and the Claude and Cursor services kept their own 10-minute file caches on top. The refresh button never refreshed quota at all. - An open window checks quota at most every 5 minutes. Background floors (15 min on AC, 30 on battery) are unchanged. - Claude and Cursor file caches drop to 4 minutes so they no longer stack on the app's interval. - Refresh and Cmd-R refresh quota too, skipping those caches. - A transient failure (rate limit, network) keeps the last good reading instead of hiding the row; login and key errors still replace it. PRIVACY.md lists the new timing. Co-Authored-By: Claude Opus 5.5 --- .../TokenStepSwift/App/TokenStepApp.swift | 2 +- .../Services/ClaudeQuotaService.swift | 8 +++++--- .../Services/CursorQuotaService.swift | 7 ++++--- .../Services/QuotaRefreshCoordinator.swift | 11 ++++++----- .../TokenStepSwift/Stores/AppState.swift | 17 +++++++++++++++-- .../Support/EnergyRefreshPolicy.swift | 5 ++++- .../TokenStepSwift/Views/MainWindowView.swift | 2 +- .../TokenStepSwift/Views/PopoverPanelView.swift | 2 +- docs/PRIVACY.md | 6 +++--- 9 files changed, 40 insertions(+), 20 deletions(-) diff --git a/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift b/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift index 35144d1..c378091 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift @@ -65,7 +65,7 @@ struct TokenStepApp: App { .commands { CommandMenu("TokenStep") { Button(L("刷新")) { - appState.refresh() + appState.refreshNow() } .keyboardShortcut("r", modifiers: [.command]) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/ClaudeQuotaService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/ClaudeQuotaService.swift index b633e1a..ae2aaec 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/ClaudeQuotaService.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/ClaudeQuotaService.swift @@ -2,11 +2,13 @@ import Foundation enum ClaudeQuotaService { private static let keychainServices = ["Claude Code-credentials"] - private static let cacheTTL: TimeInterval = 10 * 60 + // Shorter than EnergyRefreshPolicy.quotaTTL so the file cache only spares + // repeat calls across relaunches and never adds to the app's own interval. + private static let cacheTTL: TimeInterval = 4 * 60 private static let endpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")! - static func read() throws -> CodexQuotaSnapshot { - if let cached = readFreshCache() { + static func read(useCache: Bool = true) throws -> CodexQuotaSnapshot { + if useCache, let cached = readFreshCache() { return cached } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/CursorQuotaService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/CursorQuotaService.swift index 5a49a2a..0433165 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/CursorQuotaService.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/CursorQuotaService.swift @@ -4,8 +4,8 @@ enum CursorQuotaService { static var databaseURL: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/Cursor/User/globalStorage/state.vscdb") - static func read() throws -> ProviderQuota { - if let cached = readFreshCache() { + static func read(useCache: Bool = true) throws -> ProviderQuota { + if useCache, let cached = readFreshCache() { return cached } let token = try readAccessToken() @@ -221,7 +221,8 @@ enum CursorQuotaService { private static func readFreshCache(now: Date = Date()) -> ProviderQuota? { guard let data = try? Data(contentsOf: AppPaths.cursorQuotaCacheJSON), let cache = try? JSONDecoder().decode(ProviderQuotaCache.self, from: data), - now.timeIntervalSince(cache.fetchedAt) <= 10 * 60 + // Shorter than EnergyRefreshPolicy.quotaTTL; see ClaudeQuotaService. + now.timeIntervalSince(cache.fetchedAt) <= 4 * 60 else { return nil } let quota = cache.quota guard quota.isAvailable else { return nil } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift index 9aabe0b..cda03e5 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift @@ -1,14 +1,15 @@ import Foundation enum QuotaRefreshCoordinator { - static func fetch(providers: Set) -> [QuotaProviderID: ProviderQuota] { + /// `bypassCache` skips provider file caches, for a refresh the user asked for. + static func fetch(providers: Set, bypassCache: Bool = false) -> [QuotaProviderID: ProviderQuota] { var result: [QuotaProviderID: ProviderQuota] = [:] let lock = NSLock() let group = DispatchGroup() for provider in providers { group.enter() DispatchQueue.global(qos: .utility).async { - let quota = read(provider) + let quota = read(provider, bypassCache: bypassCache) lock.lock() result[provider] = quota lock.unlock() @@ -23,15 +24,15 @@ enum QuotaRefreshCoordinator { return result } - static func read(_ provider: QuotaProviderID) -> ProviderQuota { + static func read(_ provider: QuotaProviderID, bypassCache: Bool = false) -> ProviderQuota { do { switch provider { case .codex: return try CodexQuotaService.read().asProviderQuota(.codex) case .claude: - return try ClaudeQuotaService.read().asProviderQuota(.claude) + return try ClaudeQuotaService.read(useCache: !bypassCache).asProviderQuota(.claude) case .cursor: - return try CursorQuotaService.read() + return try CursorQuotaService.read(useCache: !bypassCache) case .glm: return try GLMQuotaService.read() case .kimi: diff --git a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift index 958ac26..d16c520 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift @@ -195,6 +195,12 @@ final class AppState: ObservableObject { autostartEnabled = AutostartService.isEnabled } + /// The refresh button and ⌘R: usage plus quotas, skipping quota caches. + func refreshNow() { + refresh() + refreshCodexQuota(force: true, bypassCache: true) + } + func refresh(forceCollection: Bool = true) { guard !isRefreshing else { if forceCollection { @@ -280,7 +286,7 @@ final class AppState: ObservableObject { configureForegroundTimer() } - func refreshCodexQuota(force: Bool = false, now: Date = Date()) { + func refreshCodexQuota(force: Bool = false, bypassCache: Bool = false, now: Date = Date()) { refreshCursorOfficialUsage(force: force, now: now) let providers = settings.enabledQuotaProviders guard !providers.isEmpty else { @@ -301,10 +307,17 @@ final class AppState: ObservableObject { isRefreshingCodexQuota = true Task { let fetched = await Task.detached(priority: .utility) { - QuotaRefreshCoordinator.fetch(providers: providers) + QuotaRefreshCoordinator.fetch(providers: providers, bypassCache: bypassCache) }.value for provider in providers { if let quota = fetched[provider] { + // A transient failure (rate limit, network) keeps the last + // good reading; its "N minutes ago" shows how old it is. + // Login and key problems still replace it. + if !quota.isAvailable, quota.status == .unavailable, + quotas[provider]?.isAvailable == true { + continue + } quotas[provider] = quota } else if quotas[provider]?.isAvailable != true { quotas[provider] = .unavailable(provider) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/EnergyRefreshPolicy.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/EnergyRefreshPolicy.swift index 4f973c4..9ab3c3e 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/EnergyRefreshPolicy.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/EnergyRefreshPolicy.swift @@ -9,7 +9,10 @@ enum TokenStepPowerSource: Equatable { enum EnergyRefreshPolicy { static let acBackgroundFloorSeconds = 15 * 60 static let batteryBackgroundFloorSeconds = 30 * 60 - static let quotaTTL: TimeInterval = 15 * 60 + /// Quotas move fast during heavy use, and the calls are small, so an open + /// popover checks them every 5 minutes. Background refresh still follows + /// the 15/30-minute floors above. + static let quotaTTL: TimeInterval = 5 * 60 static let rankTTL: TimeInterval = 30 * 60 static let minimumAutomaticRetryTTL: TimeInterval = 60 static let maximumForegroundTickSeconds = 60 diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift index 9e17d27..17d0f6f 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift @@ -128,7 +128,7 @@ struct MainWindowView: View { .overlay(Capsule().stroke(Color.tokenDivider)) Button { - appState.refresh() + appState.refreshNow() } label: { Image(systemName: "arrow.clockwise") .font(.system(size: 12, weight: .heavy)) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift index 2b17ce9..fc3461c 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift @@ -133,7 +133,7 @@ struct PopoverPanelView: View { private var footer: some View { HStack(spacing: 4) { Button { - appState.refresh() + appState.refreshNow() } label: { Group { if appState.isRefreshing { diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 9ae9190..b3ea809 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -22,9 +22,9 @@ Token totals are always computed locally. The app makes only the requests listed | Update check | `api.github.com/repos/Backtthefuture/TokenStep/releases/latest`, falling back to `github.com/.../releases/latest` | Yes (Settings → Check for updates automatically) | At launch, periodically, when a window comes to the front, and when you click Check Updates | A `User-Agent` header with the app version. No usage data. | | Update download | `github.com/Backtthefuture/TokenStep/releases/download/...` | Only after you confirm an update | When you choose Install | Nothing beyond a standard download request. | | Token Rank board | `www.zhenganhuo.com/api/token-rank/leaderboard.php` | Automatic: only if a local Token Rank account exists in `~/.token-rank/client-state.json`; can be hidden in Settings | At launch, with background refreshes, and when a window comes to the front; at most every 30 minutes | A read-only request with the board filters (`client`, `range`, `usage_mode`). TokenStep uploads nothing. Uploading to the board is done by the separate Token Rank client, not by TokenStep. | -| Codex quota | Runs the local `codex app-server`, which talks to OpenAI with your Codex login | No | When quota display is on, at most every 15 minutes | Handled by the Codex CLI. TokenStep only reads the rate-limit response. | -| Claude Code quota | `api.anthropic.com/api/oauth/usage` | No | When quota display is on, at most every 15 minutes | Your Claude Code OAuth access token, read from the macOS Keychain item Claude Code created. | -| Cursor quota and usage events | `cursor.com/api/usage-summary`, `cursor.com/api/usage`, `cursor.com/api/dashboard/get-filtered-usage-events` (fallback `api2.cursor.sh`) | No | When Cursor is turned on, at most every 15 minutes | Your Cursor session token, read from Cursor's local `state.vscdb`. It is kept in memory only. | +| Codex quota | Runs the local `codex app-server`, which talks to OpenAI with your Codex login | No | When quota display is on: at most every 5 minutes while a TokenStep window is open, at most every 15 minutes in the background, and when you click Refresh | Handled by the Codex CLI. TokenStep only reads the rate-limit response. | +| Claude Code quota | `api.anthropic.com/api/oauth/usage` | No | When quota display is on: at most every 5 minutes while a TokenStep window is open, at most every 15 minutes in the background, and when you click Refresh | Your Claude Code OAuth access token, read from the macOS Keychain item Claude Code created. | +| Cursor quota and usage events | `cursor.com/api/usage-summary`, `cursor.com/api/usage`, `cursor.com/api/dashboard/get-filtered-usage-events` (fallback `api2.cursor.sh`) | No | When Cursor is turned on: at most every 5 minutes while a TokenStep window is open, at most every 15 minutes in the background, and when you click Refresh | Your Cursor session token, read from Cursor's local `state.vscdb`. It is kept in memory only. | | GLM quota | `open.bigmodel.cn` or `api.z.ai` usage endpoints | No | When GLM is turned on | The API key you entered. It is stored in the macOS Keychain. | | Kimi quota | `api.kimi.com` or `www.kimi.com` usage endpoints | No | When Kimi is turned on | The token from the local Kimi CLI login (`~/.kimi`), or the token you entered, which is stored in the macOS Keychain. | | Grok quota | `cli-chat-proxy.grok.com/v1/billing` | No | When Grok is turned on | The session from the local Grok CLI login (`~/.grok/auth.json`), or the token you entered, which is stored in the macOS Keychain. | From d63fdaa3023587f5fda7483e57c463b683bc4810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AI=E4=BA=A7=E5=93=81=E9=BB=84=E5=8F=94?= Date: Fri, 25 Sep 2026 21:02:48 -0700 Subject: [PATCH 5/5] chore: prepare TokenStep 0.2.16 Add release notes, the CHANGELOG entry, README download links, and the default build version. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ README.md | 10 +++++----- docs/RELEASE_NOTES_0.2.16.md | 12 ++++++++++++ script/build_swiftui_and_run.sh | 2 +- 4 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 docs/RELEASE_NOTES_0.2.16.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1f400..3c363c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ TokenStep 各版本的更新内容,新版本在前。每个版本的完整说明在 [docs/RELEASE_NOTES_<版本号>.md](docs/)。 +## 0.2.16 更新:紧凑面板,额度刷新更及时 + +菜单栏面板改为一列 340pt,只放额度、今日 Token、今日排名,13 寸屏幕上不再被截掉;刘海旁的 Token Island 打开同一个面板。额度在面板打开时最多每 5 分钟刷新,刷新按钮会一并刷新额度;只用 Claude Code CLI 时也显示 Claude 图标。详见 [0.2.16 发布说明](docs/RELEASE_NOTES_0.2.16.md)。 + ## 0.2.15 更新:按本地时区统计,采集更稳更省电 每天的用量改为按 Mac 的系统时区切分,更换时区后自动重新统计;修复 Codex 从本地数据库回退统计时可能卡住的问题;Claude Code 长会话只读取新增内容,读取本地数据库不再启动额外进程;设置中填写的密钥不再出现在进程列表里;隐私页列出每一个联网请求。详见 [0.2.15 发布说明](docs/RELEASE_NOTES_0.2.15.md)。 diff --git a/README.md b/README.md index 9fdb25e..b15471f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ TokenStep 是一个 macOS 菜单栏 App,用来本地统计你在 Codex、Claud 下载最新版 DMG,打开后把 `TokenStep.app` 拖进「应用程序」即可使用: -[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.15.dmg) +[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.16.dmg) 也可以从 Release 页面查看所有版本: @@ -32,9 +32,9 @@ TokenStep 已使用 Developer ID 签名并通过 Apple 公证。首次打开时 Windows版本由十七做了移植,欢迎大家前往使用:https://github.com/canyexuanfan/TokenStep-Windows/releases -## 最新版本:0.2.15 +## 最新版本:0.2.16 -按 Mac 的系统时区统计每天的用量,修复 Codex 回退统计时可能卡住的问题,后台采集更省电,隐私页列出每一个联网请求。详见 [0.2.15 发布说明](docs/RELEASE_NOTES_0.2.15.md)。 +菜单栏面板改为一列紧凑面板,13 寸屏幕上不再被截掉,刘海旁的 Token Island 打开同一个面板;额度在面板打开时最多每 5 分钟刷新,刷新按钮会一并刷新额度。详见 [0.2.16 发布说明](docs/RELEASE_NOTES_0.2.16.md)。 历次更新(主题包、引力动效、自动更新等)见 [CHANGELOG.md](CHANGELOG.md)。 @@ -95,7 +95,7 @@ TokenStep 默认只做本地统计。 ## 安装方式 -1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.15.dmg)。 +1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.16.dmg)。 2. 打开 DMG。 3. 把 `TokenStep.app` 拖到「应用程序」。 4. 启动 TokenStep。 @@ -166,7 +166,7 @@ TokenStepSwift/dist/TokenStep.app 公开发布强制执行 Developer ID 签名、Apple 公证、票据装订、系统分发检查和隔离安装验证。不再生成可被误上传的“仅签名、未公证”发布包: ```bash -TOKENSTEP_VERSION=0.2.15 \ +TOKENSTEP_VERSION=0.2.16 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \ ./script/package_release.sh --notarize diff --git a/docs/RELEASE_NOTES_0.2.16.md b/docs/RELEASE_NOTES_0.2.16.md new file mode 100644 index 0000000..028f0a3 --- /dev/null +++ b/docs/RELEASE_NOTES_0.2.16.md @@ -0,0 +1,12 @@ +# TokenStep 0.2.16 + +菜单栏面板改成一列 340pt 的紧凑面板,在 13 寸屏幕上不再被截掉;额度刷新更及时。 + +- **紧凑面板**:以前的面板宽 900pt,在 13 寸屏幕上左侧会被截掉。现在只放三块最常看的内容:额度、今日 Token、今日排名。分享、截图、退出收进「…」菜单;客户端明细和完整模型列表仍在仪表盘里。 +- **刘海 Mac 同步改版**:点刘海旁的 Token Island 打开的也是同一个新面板,高度跟随内容变化。 +- **额度更好懂**:每行额度的竖线表示这个窗口的时间已经过去多少;用量跑在时间前面时,会提示「照此速度会提前用完」,并标出是哪一行。鼠标停在某一行上可以看到具体数字。 +- **额度刷新更及时**:面板打开时最多每 5 分钟刷新一次额度(以前最长可能 25 分钟才更新),点刷新按钮或按 ⌘R 会立即刷新额度。偶尔查询失败时保留上一次读数,不再整行消失。后台刷新频率不变。 +- **Claude Code 图标**:只用 Claude Code CLI、没有安装 Claude 桌面 App 时,额度区也显示 Claude 的图标。 +- **排名**:今天还没上榜时显示「未上榜」,不再显示一条横线;「再跑多少超过下一名」的字号调小。 + +Token、金额与额度的统计口径不变。 diff --git a/script/build_swiftui_and_run.sh b/script/build_swiftui_and_run.sh index d9ad892..8b25522 100755 --- a/script/build_swiftui_and_run.sh +++ b/script/build_swiftui_and_run.sh @@ -23,7 +23,7 @@ HELPER_EXECUTABLE="$BUILD_DIR/$HELPER_NAME" ICON_FILE="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" ODYSSEY_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey" INTERSTELLAR_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar" -VERSION="${TOKENSTEP_VERSION:-0.2.15}" +VERSION="${TOKENSTEP_VERSION:-0.2.16}" LAUNCH=true VERIFY=false