Skip to content

Commit 88eb603

Browse files
authored
Merge pull request #1314 from hhh2210/codex/menu-card-height-fingerprint
Cache menu card heights and skip closed merged-menu rebuild
2 parents de55f48 + 400f98a commit 88eb603

17 files changed

Lines changed: 570 additions & 33 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## 0.32.5 — Unreleased
44

55
### Fixed
6-
- Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286). Thanks @hhh2210!
6+
- Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210!
77
- Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210!
88

99
## 0.32.4 — 2026-06-02

Sources/CodexBar/Localization.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@ private func appLanguageDefaults() -> UserDefaults {
1616
return UserDefaults(suiteName: "CodexBar") ?? .standard
1717
}
1818

19-
private func isRunningTestsProcess() -> Bool {
19+
private let isRunningTestsProcessAtStartup: Bool = {
2020
let env = ProcessInfo.processInfo.environment
2121
if env["XCTestConfigurationFilePath"] != nil { return true }
2222
if env["TESTING_LIBRARY_VERSION"] != nil { return true }
2323
if env["SWIFT_TESTING"] != nil { return true }
2424
return NSClassFromString("XCTestCase") != nil
25+
}()
26+
27+
private func isRunningTestsProcess() -> Bool {
28+
isRunningTestsProcessAtStartup
2529
}
2630

2731
private let standardAppLanguageAtProcessStart = UserDefaults.standard.string(forKey: "appLanguage")
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import Foundation
2+
3+
extension UsageMenuCardView.Model {
4+
func heightFingerprint(section: String, additional: [String] = []) -> String {
5+
let notesFingerprint = MenuCardHeightFingerprint.join(self.usageNotes.map {
6+
MenuCardHeightFingerprint.field("note", $0)
7+
})
8+
return MenuCardHeightFingerprint.join([
9+
"section=\(section)",
10+
"provider=\(self.provider.rawValue)",
11+
"localization=\(codexBarLocalizationSignature())",
12+
MenuCardHeightFingerprint.field("name", self.providerName),
13+
MenuCardHeightFingerprint.field("email", self.email),
14+
MenuCardHeightFingerprint.field("subtitle", self.subtitleText),
15+
"subtitleStyle=\(self.subtitleStyle.heightFingerprint)",
16+
MenuCardHeightFingerprint.field("plan", self.planText),
17+
MenuCardHeightFingerprint.field("placeholder", self.placeholder),
18+
MenuCardHeightFingerprint.field("credits", self.creditsText),
19+
"creditsRemaining=\(self.creditsRemaining.map(String.init(describing:)) ?? "nil")",
20+
MenuCardHeightFingerprint.field("creditsHint", self.creditsHintText),
21+
MenuCardHeightFingerprint.field("creditsCopy", self.creditsHintCopyText),
22+
"metrics=\(MenuCardHeightFingerprint.join(self.metrics.map(\.heightFingerprint)))",
23+
"notes=\(notesFingerprint)",
24+
"dashboard=\(self.inlineUsageDashboard?.heightFingerprint ?? "")",
25+
"providerCost=\(self.providerCost?.heightFingerprint ?? "")",
26+
"tokenUsage=\(self.tokenUsage?.heightFingerprint ?? "")",
27+
"openaiAPI=\(self.openAIAPIUsage == nil ? "0" : "1")",
28+
] + additional)
29+
}
30+
31+
static func heightFingerprintField(_ name: String, _ value: String?) -> String {
32+
MenuCardHeightFingerprint.field(name, value)
33+
}
34+
}
35+
36+
private enum MenuCardHeightFingerprint {
37+
private static let hashSalt = UUID()
38+
39+
static func join(_ values: [String]) -> String {
40+
values.map { "\($0.count):\($0)" }.joined(separator: "|")
41+
}
42+
43+
static func field(_ name: String, _ value: String?) -> String {
44+
guard let value else {
45+
return "\(name)=nil"
46+
}
47+
return "\(name)=\(Self.stringShape(value))"
48+
}
49+
50+
private static func stringShape(_ value: String) -> String {
51+
var hasher = Hasher()
52+
hasher.combine(Self.hashSalt)
53+
hasher.combine(value)
54+
let digest = String(UInt(bitPattern: hasher.finalize()), radix: 16)
55+
return "chars:\(value.count),utf8:\(value.utf8.count),lines:\(Self.lineCount(value)),hash:\(digest)"
56+
}
57+
58+
private static func lineCount(_ value: String) -> Int {
59+
guard !value.isEmpty else { return 0 }
60+
return value.utf8.reduce(1) { count, byte in
61+
byte == 10 ? count + 1 : count
62+
}
63+
}
64+
}
65+
66+
extension UsageMenuCardView.Model.SubtitleStyle {
67+
fileprivate var heightFingerprint: String {
68+
switch self {
69+
case .info: "info"
70+
case .loading: "loading"
71+
case .error: "error"
72+
}
73+
}
74+
}
75+
76+
extension UsageMenuCardView.Model.Metric {
77+
fileprivate var heightFingerprint: String {
78+
MenuCardHeightFingerprint.join([
79+
self.id,
80+
MenuCardHeightFingerprint.field("title", self.title),
81+
"percent=\(Int(self.percent.rounded()))",
82+
"percentStyle=\(self.percentStyle.rawValue)",
83+
MenuCardHeightFingerprint.field("status", self.statusText),
84+
MenuCardHeightFingerprint.field("reset", self.resetText),
85+
MenuCardHeightFingerprint.field("detail", self.detailText),
86+
MenuCardHeightFingerprint.field("detailLeft", self.detailLeftText),
87+
MenuCardHeightFingerprint.field("detailRight", self.detailRightText),
88+
self.pacePercent == nil ? "pace=0" : "pace=1",
89+
self.paceOnTop ? "paceTop=1" : "paceTop=0",
90+
self.cardStyle ? "card=1" : "card=0",
91+
"markers=\(self.warningMarkerPercents.count)",
92+
])
93+
}
94+
}
95+
96+
extension UsageMenuCardView.Model.ProviderCostSection {
97+
fileprivate var heightFingerprint: String {
98+
MenuCardHeightFingerprint.join([
99+
MenuCardHeightFingerprint.field("title", self.title),
100+
MenuCardHeightFingerprint.field("spend", self.spendLine),
101+
MenuCardHeightFingerprint.field("percentLine", self.percentLine),
102+
self.percentUsed == nil ? "percent=0" : "percent=1",
103+
])
104+
}
105+
}
106+
107+
extension UsageMenuCardView.Model.TokenUsageSection {
108+
fileprivate var heightFingerprint: String {
109+
MenuCardHeightFingerprint.join([
110+
MenuCardHeightFingerprint.field("session", self.sessionLine),
111+
MenuCardHeightFingerprint.field("month", self.monthLine),
112+
MenuCardHeightFingerprint.field("hint", self.hintLine),
113+
MenuCardHeightFingerprint.field("error", self.errorLine),
114+
MenuCardHeightFingerprint.field("errorCopy", self.errorCopyText),
115+
])
116+
}
117+
}
118+
119+
extension InlineUsageDashboardModel {
120+
fileprivate var heightFingerprint: String {
121+
MenuCardHeightFingerprint.join([
122+
MenuCardHeightFingerprint.field("accessibility", self.accessibilityLabel),
123+
self.valueStyle.heightFingerprint,
124+
MenuCardHeightFingerprint.join(self.kpis.map(\.heightFingerprint)),
125+
MenuCardHeightFingerprint.join(self.points.map(\.heightFingerprint)),
126+
MenuCardHeightFingerprint.join(self.detailLines.map { MenuCardHeightFingerprint.field("detail", $0) }),
127+
])
128+
}
129+
}
130+
131+
extension InlineUsageDashboardModel.KPI {
132+
fileprivate var heightFingerprint: String {
133+
MenuCardHeightFingerprint.join([
134+
MenuCardHeightFingerprint.field("title", self.title),
135+
MenuCardHeightFingerprint.field("value", self.value),
136+
self.emphasis ? "1" : "0",
137+
])
138+
}
139+
}
140+
141+
extension InlineUsageDashboardModel.Point {
142+
fileprivate var heightFingerprint: String {
143+
MenuCardHeightFingerprint.join([
144+
self.id,
145+
MenuCardHeightFingerprint.field("label", self.label),
146+
MenuCardHeightFingerprint.field("accessibilityValue", self.accessibilityValue),
147+
])
148+
}
149+
}
150+
151+
extension InlineUsageDashboardModel.ValueStyle {
152+
fileprivate var heightFingerprint: String {
153+
switch self {
154+
case .currencyUSD:
155+
"currencyUSD"
156+
case let .currency(symbol):
157+
"currency:\(symbol)"
158+
case .tokens:
159+
"tokens"
160+
}
161+
}
162+
}

Sources/CodexBar/StatusItemController+CodexStackedMenu.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ extension StatusItemController {
3333
UsageMenuCardView(model: model, width: context.menuWidth),
3434
id: "menuCard-\(cardIndex)",
3535
width: context.menuWidth,
36-
heightCacheScope: account.id))
36+
heightCacheScope: account.id,
37+
heightCacheFingerprint: model.heightFingerprint(section: "card")))
3738
cardIndex += 1
3839
if account.id != section.accounts.last?.id {
3940
menu.addItem(.separator())
@@ -50,7 +51,8 @@ extension StatusItemController {
5051
UsageMenuCardView(model: model, width: context.menuWidth),
5152
id: "menuCard",
5253
width: context.menuWidth,
53-
heightCacheScope: context.currentProvider.rawValue))
54+
heightCacheScope: context.currentProvider.rawValue,
55+
heightCacheFingerprint: model.heightFingerprint(section: "card")))
5456
}
5557
menu.addItem(.separator())
5658
if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) {

Sources/CodexBar/StatusItemController+Menu.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,9 @@ extension StatusItemController {
550550
id: identifier,
551551
width: menuWidth,
552552
heightCacheScope: row.provider.rawValue,
553+
heightCacheFingerprint: row.model.heightFingerprint(
554+
section: "overview",
555+
additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]),
553556
submenu: submenu,
554557
onClick: { [weak self, weak menu] in
555558
guard let self, let menu else { return }
@@ -633,7 +636,8 @@ extension StatusItemController {
633636
UsageMenuCardView(model: model, width: context.menuWidth),
634637
id: "menuCard",
635638
width: context.menuWidth,
636-
heightCacheScope: context.currentProvider.rawValue))
639+
heightCacheScope: context.currentProvider.rawValue,
640+
heightCacheFingerprint: model.heightFingerprint(section: "card")))
637641
if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) {
638642
menu.addItem(.separator())
639643
}
@@ -654,15 +658,17 @@ extension StatusItemController {
654658
UsageMenuCardView(model: model, width: context.menuWidth),
655659
id: "menuCard",
656660
width: context.menuWidth,
657-
heightCacheScope: context.currentProvider.rawValue))
661+
heightCacheScope: context.currentProvider.rawValue,
662+
heightCacheFingerprint: model.heightFingerprint(section: "card")))
658663
menu.addItem(.separator())
659664
} else {
660665
for (index, model) in cards.enumerated() {
661666
menu.addItem(self.makeMenuCardItem(
662667
UsageMenuCardView(model: model, width: context.menuWidth),
663668
id: "menuCard-\(index)",
664669
width: context.menuWidth,
665-
heightCacheScope: "\(context.currentProvider.rawValue)-\(index)"))
670+
heightCacheScope: "\(context.currentProvider.rawValue)-\(index)",
671+
heightCacheFingerprint: model.heightFingerprint(section: "card")))
666672
if index < cards.count - 1 {
667673
menu.addItem(.separator())
668674
}
@@ -1197,6 +1203,7 @@ extension StatusItemController {
11971203
id: "menuCardUsage",
11981204
width: width,
11991205
heightCacheScope: provider.rawValue,
1206+
heightCacheFingerprint: model.heightFingerprint(section: "usage"),
12001207
submenu: usageSubmenu))
12011208
} else {
12021209
let headerView = UsageMenuCardHeaderSectionView(
@@ -1207,7 +1214,8 @@ extension StatusItemController {
12071214
headerView,
12081215
id: "menuCardHeader",
12091216
width: width,
1210-
heightCacheScope: provider.rawValue))
1217+
heightCacheScope: provider.rawValue,
1218+
heightCacheFingerprint: model.heightFingerprint(section: "header")))
12111219
}
12121220

12131221
if hasStorage || hasCredits || hasExtraUsage || hasCost {
@@ -1236,6 +1244,7 @@ extension StatusItemController {
12361244
id: "menuCardCredits",
12371245
width: width,
12381246
heightCacheScope: provider.rawValue,
1247+
heightCacheFingerprint: model.heightFingerprint(section: "credits"),
12391248
submenu: creditsSubmenu))
12401249
if webItems.canShowBuyCredits {
12411250
menu.addItem(self.makeBuyCreditsItem())
@@ -1256,6 +1265,7 @@ extension StatusItemController {
12561265
id: "menuCardExtraUsage",
12571266
width: width,
12581267
heightCacheScope: provider.rawValue,
1268+
heightCacheFingerprint: model.heightFingerprint(section: "extraUsage"),
12591269
submenu: extraUsageSubmenu))
12601270
}
12611271
if hasCost {
@@ -1282,6 +1292,7 @@ extension StatusItemController {
12821292
id: "menuCardStorage",
12831293
width: width,
12841294
heightCacheScope: provider.rawValue,
1295+
heightCacheFingerprint: UsageMenuCardView.Model.heightFingerprintField("storage", storageText),
12851296
submenu: storageSubmenu))
12861297
return true
12871298
}

Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,33 @@ extension StatusItemController {
55
let id: String
66
let scope: String
77
let width: Int
8-
let version: Int
8+
let textScale: Int
9+
let fingerprint: String
10+
}
11+
12+
/// Measured card height also depends on the resolved font sizes, which the menu cards
13+
/// derive from semantic text styles (`.body`, `.footnote`, …). Those scale with the
14+
/// macOS system text-size / Dynamic Type setting, which is neither part of the content
15+
/// fingerprint nor invalidated on rebuild. Fold the current resolved scale into the key
16+
/// so a runtime text-size change forces a fresh measurement instead of returning a
17+
/// height measured at the old scale (clipped / over-tall cards).
18+
static func menuCardHeightTextScaleToken() -> Int {
19+
Int((NSFont.preferredFont(forTextStyle: .body).pointSize * 100).rounded())
920
}
1021

1122
func cachedMenuCardHeight(
1223
for id: String,
1324
scope: String,
1425
width: CGFloat,
26+
fingerprint: String? = nil,
1527
measure: () -> CGFloat) -> CGFloat
1628
{
1729
let key = MenuCardHeightCacheKey(
1830
id: id,
1931
scope: scope,
2032
width: Int((width * 100).rounded()),
21-
version: self.menuContentVersion)
33+
textScale: Self.menuCardHeightTextScaleToken(),
34+
fingerprint: fingerprint ?? "version:\(self.menuContentVersion)")
2235
if let cached = self.menuCardHeightCache[key] {
2336
return cached
2437
}
@@ -29,4 +42,13 @@ extension StatusItemController {
2942
self.menuCardHeightCache[key] = height
3043
return height
3144
}
45+
46+
func pruneVersionScopedMenuCardHeightCache() {
47+
let currentVersionFingerprint = "version:\(self.menuContentVersion)"
48+
for key in self.menuCardHeightCache.keys
49+
where key.fingerprint.hasPrefix("version:") && key.fingerprint != currentVersionFingerprint
50+
{
51+
self.menuCardHeightCache.removeValue(forKey: key)
52+
}
53+
}
3254
}

Sources/CodexBar/StatusItemController+MenuCardItems.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ extension StatusItemController {
2525
id: String,
2626
width: CGFloat,
2727
heightCacheScope: String? = nil,
28+
heightCacheFingerprint: String? = nil,
2829
submenu: NSMenu? = nil,
2930
submenuIndicatorAlignment: Alignment = .topTrailing,
3031
submenuIndicatorTopPadding: CGFloat = 8,
@@ -52,7 +53,12 @@ extension StatusItemController {
5253
view
5354
}
5455
let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick)
55-
let height = self.cachedMenuCardHeight(for: id, scope: heightCacheScope ?? id, width: width) {
56+
let height = self.cachedMenuCardHeight(
57+
for: id,
58+
scope: heightCacheScope ?? id,
59+
width: width,
60+
fingerprint: heightCacheFingerprint)
61+
{
5662
self.menuCardHeight(for: hosting, width: width)
5763
}
5864
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height))

Sources/CodexBar/StatusItemController+MenuCardModel.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ extension StatusItemController {
8585
self.store.weeklyPace(provider: target, window: window, now: now)
8686
}
8787
}
88+
let fallbackAccount = accountOverride
89+
?? (metadata.usesAccountFallback
90+
? self.store.accountInfo(for: target)
91+
: AccountInfo(email: nil, plan: nil))
8892
let input = UsageMenuCardView.Model.Input(
8993
provider: target,
9094
metadata: metadata,
@@ -96,7 +100,7 @@ extension StatusItemController {
96100
dashboardError: dashboardError,
97101
tokenSnapshot: tokenSnapshot,
98102
tokenError: tokenError,
99-
account: accountOverride ?? self.store.accountInfo(for: target),
103+
account: fallbackAccount,
100104
isRefreshing: self.store.shouldShowRefreshingMenuCard(for: target),
101105
lastError: errorOverride
102106
?? codexProjection?.userFacingErrors.usage

0 commit comments

Comments
 (0)