diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 1f080abe69..b4c7bfad2d 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -186,9 +186,9 @@ extension StatusItemController { let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -213,9 +213,9 @@ extension StatusItemController { let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -252,9 +252,9 @@ extension StatusItemController { windowLabel: tokenSnapshot.historyLabel, width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -288,9 +288,9 @@ extension StatusItemController { let maxHeight = self.storageBreakdownMenuMaxHeight() let view = StorageBreakdownMenuView(footprint: footprint, width: width, maxHeight: maxHeight) let hosting = MenuHostingView(rootView: view) - let controller = NSHostingController(rootView: view) - let size = controller.sizeThatFits(in: CGSize(width: width, height: maxHeight)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let item = NSMenuItem() item.view = hosting @@ -328,9 +328,9 @@ extension StatusItemController { let chartView = ZaiHourlyUsageChartMenuView(modelUsage: modelUsage, width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 55a5b84703..7b1a756419 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1541,13 +1541,22 @@ extension StatusItemController { for item in menu.items { guard let view = item.view else { continue } - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) - view.layoutSubtreeIfNeeded() - let height = view.fittingSize.height + let height = self.hostedSubviewFittingHeight(for: view, width: width) view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) } } + /// Measures the natural height of a hosted submenu view at the given width using the live + /// view that will actually be displayed. Hosted chart items used to spin up a second, + /// throwaway `NSHostingController` purely to size the chart even though every build path + /// immediately re-measures the live view via `fittingSize`; that extra SwiftUI hierarchy was + /// pure overhead on a popup-menu hot path, so callers now size the displayed view directly. + func hostedSubviewFittingHeight(for view: NSView, width: CGFloat) -> CGFloat { + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + view.layoutSubtreeIfNeeded() + return view.fittingSize.height + } + @objc func menuCardNoOp(_ sender: NSMenuItem) { _ = sender } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 6b5dbc1f7d..5bc0bbed0a 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -68,9 +68,9 @@ extension StatusItemController { snapshot: snapshot, width: width) let hosting = UsageHistoryMenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index a528c5c518..2a6323058b 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -171,6 +171,76 @@ struct StatusMenuHostedSubmenuRefreshTests { seed: Self.seedZaiHourlyUsage) } + @Test + func `hosted chart items size to the displayed view without a throwaway controller`() throws { + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + seed: Self.seedClaudeSnapshots) + { controller, submenu, width in + controller.appendCostHistoryChartItem(to: submenu, provider: .claude, width: width) + } + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + seed: Self.seedPlanUtilizationHistory) + { controller, submenu, width in + controller.appendUsageHistoryChartItem(to: submenu, provider: .claude, width: width) + } + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.storageBreakdownID, + provider: .claude, + seed: Self.seedStorageFootprint) + { controller, submenu, width in + controller.appendStorageBreakdownItem(to: submenu, provider: .claude, width: width) + } + } + + private func assertHostedChartItemHeightMatchesRefresh( + chartID: String, + provider: UsageProvider, + seed: (UsageStore) -> Void, + append: (StatusItemController, NSMenu, CGFloat) -> Bool) throws + { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = true + settings.providerStorageFootprintsEnabled = true + Self.enableOnly(settings, provider: provider) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + seed(store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let width = StatusItemController.menuCardBaseWidth + let submenu = NSMenu() + submenu.minimumWidth = width + #expect(append(controller, submenu, width)) + + let item = try #require(submenu.items.first) + let view = try #require(item.view) + let heightFromAppend = view.frame.height + // The height the append path assigns must match the authoritative re-measure pass; otherwise + // dropping the throwaway NSHostingController would have changed sizing behavior. + controller.refreshHostedSubviewHeights(in: submenu) + #expect(view.frame.height == heightFromAppend) + #expect(heightFromAppend > 1) + } + private func assertHostedSubmenuPreservesIdentity( chartID: String, provider: UsageProvider,