Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions Sources/CodexBar/StatusItemController+HostedSubmenus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import CodexBarCore
import SwiftUI

extension StatusItemController {
private static let hostedSubviewWidth: CGFloat = 310

func makeHostedSubviewPlaceholderMenu(chartID: String, provider: UsageProvider? = nil) -> NSMenu {
let submenu = NSMenu()
submenu.delegate = self
Expand All @@ -25,7 +23,7 @@ extension StatusItemController {
return
}

let width = Self.hostedSubviewWidth
let width = self.renderedMenuWidth(for: menu.supermenu ?? menu)
menu.removeAllItems()

let didHydrate: Bool = switch chartID {
Expand Down
119 changes: 83 additions & 36 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,25 @@ extension StatusItemController {
}
}

private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat {
_ = menu
return Self.menuCardBaseWidth
private func menuCardWidth(
for providers: [UsageProvider],
sections: [MenuDescriptor.Section]) -> CGFloat
{
_ = providers
let baselineWidth = Self.menuCardBaseWidth
return max(baselineWidth, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth))
}

private func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat {
let measuringMenu = NSMenu()
measuringMenu.autoenablesItems = false
self.addActionableSections(sections, to: measuringMenu, width: baseWidth)
return ceil(measuringMenu.size.width)
}

func renderedMenuWidth(for menu: NSMenu) -> CGFloat {
let measuredWidth = ceil(menu.size.width)
return max(measuredWidth, Self.menuCardBaseWidth)
}

func makeMenu() -> NSMenu {
Expand Down Expand Up @@ -127,14 +143,23 @@ extension StatusItemController {
} else {
switcherSelection?.provider ?? provider
}
let menuWidth = self.menuCardWidth(for: enabledProviders, menu: menu)
let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex
let codexAccountDisplay = isOverviewSelected ? nil : self.codexAccountMenuDisplay(for: currentProvider)
let tokenAccountDisplay = isOverviewSelected ? nil : self.tokenAccountMenuDisplay(for: currentProvider)
let showAllTokenAccounts = tokenAccountDisplay?.showAll ?? false
let openAIContext = self.openAIWebContext(
currentProvider: currentProvider,
showAllTokenAccounts: showAllTokenAccounts)
let descriptor = MenuDescriptor.build(
provider: selectedProvider,
store: self.store,
settings: self.settings,
account: self.account,
managedCodexAccountCoordinator: self.managedCodexAccountCoordinator,
codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator,
updateReady: self.updater.updateStatus.isUpdateReady,
includeContextualActions: !isOverviewSelected)
let menuWidth = self.menuCardWidth(for: enabledProviders, sections: descriptor.sections)

let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView }
let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView }
Expand All @@ -145,6 +170,10 @@ extension StatusItemController {
let tokenSwitcherCompatible = tokenAccountDisplay == nil && !hasTokenSwitcher
let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay &&
((codexAccountDisplay == nil && !hasCodexSwitcher) || (codexAccountDisplay != nil && hasCodexSwitcher))
let reusableRowWidthsMatch = self.reusableFixedWidthRows(in: menu).allSatisfy { item in
guard let view = item.view else { return false }
return abs(view.frame.width - menuWidth) <= 0.5
}
let canSmartUpdate = self.shouldMergeIcons &&
enabledProviders.count > 1 &&
!isOverviewSelected &&
Expand All @@ -154,6 +183,7 @@ extension StatusItemController {
switcherOverviewAvailabilityMatches &&
tokenSwitcherCompatible &&
codexSwitcherCompatible &&
reusableRowWidthsMatch &&
!menu.items.isEmpty &&
menu.items.first?.view is ProviderSwitcherView

Expand All @@ -168,32 +198,22 @@ extension StatusItemController {
}

menu.removeAllItems()

let descriptor = MenuDescriptor.build(
provider: selectedProvider,
store: self.store,
settings: self.settings,
account: self.account,
managedCodexAccountCoordinator: self.managedCodexAccountCoordinator,
codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator,
updateReady: self.updater.updateStatus.isUpdateReady,
includeContextualActions: !isOverviewSelected)

self.addProviderSwitcherIfNeeded(
to: menu,
enabledProviders: enabledProviders,
includesOverview: includesOverview,
selection: switcherSelection ?? .provider(currentProvider))
selection: switcherSelection ?? .provider(currentProvider),
width: menuWidth)
// Track which providers the switcher was built with for smart update detection
if self.shouldMergeIcons, enabledProviders.count > 1 {
self.lastSwitcherProviders = enabledProviders
self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed
self.lastMergedSwitcherSelection = switcherSelection
self.lastSwitcherIncludesOverview = includesOverview
}
self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay)
self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay, width: menuWidth)
self.lastCodexAccountMenuDisplay = codexAccountDisplay
self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay)
self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay, width: menuWidth)
let menuContext = MenuCardContext(
currentProvider: currentProvider,
selectedProvider: selectedProvider,
Expand All @@ -218,13 +238,36 @@ extension StatusItemController {
currentProvider: currentProvider,
context: openAIContext,
addedOpenAIWebItems: addedOpenAIWebItems)
if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) {
if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider, width: menuWidth) {
menu.addItem(.separator())
}
}
self.addActionableSections(descriptor.sections, to: menu, width: menuWidth)
}

private func reusableFixedWidthRows(in menu: NSMenu) -> [NSMenuItem] {
guard !menu.items.isEmpty else { return [] }

var reusableRows: [NSMenuItem] = []
var index = 0
if menu.items.first?.view is ProviderSwitcherView {
reusableRows.append(menu.items[0])
index = 2
}
if menu.items.count > index,
menu.items[index].view is CodexAccountSwitcherView
{
reusableRows.append(menu.items[index])
index += 2
}
if menu.items.count > index,
menu.items[index].view is TokenAccountSwitcherView
{
reusableRows.append(menu.items[index])
}
return reusableRows
}

/// Smart update: only rebuild content sections when switching providers (keep the switcher intact).
private func updateMenuContent(
_ menu: NSMenu,
Expand Down Expand Up @@ -277,7 +320,7 @@ extension StatusItemController {
currentProvider: currentProvider,
context: openAIContext,
addedOpenAIWebItems: addedOpenAIWebItems)
if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) {
if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider, width: menuWidth) {
menu.addItem(.separator())
}
self.addActionableSections(descriptor.sections, to: menu, width: menuWidth)
Expand Down Expand Up @@ -326,28 +369,30 @@ extension StatusItemController {
to menu: NSMenu,
enabledProviders: [UsageProvider],
includesOverview: Bool,
selection: ProviderSwitcherSelection)
selection: ProviderSwitcherSelection,
width: CGFloat)
{
guard self.shouldMergeIcons, enabledProviders.count > 1 else { return }
let switcherItem = self.makeProviderSwitcherItem(
providers: enabledProviders,
includesOverview: includesOverview,
selected: selection,
menu: menu)
menu: menu,
width: width)
menu.addItem(switcherItem)
menu.addItem(.separator())
}

private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?) {
private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?, width: CGFloat) {
guard let display, display.showSwitcher else { return }
let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu)
let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu, width: width)
menu.addItem(switcherItem)
menu.addItem(.separator())
}

private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?) {
private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?, width: CGFloat) {
guard let display else { return }
let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu)
let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu, width: width)
menu.addItem(switcherItem)
menu.addItem(.separator())
}
Expand Down Expand Up @@ -578,7 +623,7 @@ extension StatusItemController {
}

private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem {
let item = NSMenuItem(title: text, action: nil, keyEquivalent: "")
let item = NSMenuItem(title: "", action: nil, keyEquivalent: "")
let view = self.makeWrappedSecondaryTextView(text: text)
let height = self.menuTextItemHeight(for: view, width: width)
view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height))
Expand Down Expand Up @@ -631,13 +676,14 @@ extension StatusItemController {
providers: [UsageProvider],
includesOverview: Bool,
selected: ProviderSwitcherSelection,
menu: NSMenu) -> NSMenuItem
menu: NSMenu,
width: CGFloat) -> NSMenuItem
{
let view = ProviderSwitcherView(
providers: providers,
selected: selected,
includesOverview: includesOverview,
width: self.menuCardWidth(for: providers, menu: menu),
width: width,
showsIcons: self.settings.switcherShowsIcons,
iconProvider: { [weak self] provider in
self?.switcherIcon(for: provider) ?? NSImage()
Expand Down Expand Up @@ -672,12 +718,13 @@ extension StatusItemController {

private func makeTokenAccountSwitcherItem(
display: TokenAccountMenuDisplay,
menu: NSMenu) -> NSMenuItem
menu: NSMenu,
width: CGFloat) -> NSMenuItem
{
let view = TokenAccountSwitcherView(
accounts: display.accounts,
selectedIndex: display.activeIndex,
width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu),
width: width,
onSelect: { [weak self, weak menu] index in
guard let self, let menu else { return }
self.settings.setActiveTokenAccountIndex(index, for: display.provider)
Expand All @@ -698,12 +745,13 @@ extension StatusItemController {

private func makeCodexAccountSwitcherItem(
display: CodexAccountMenuDisplay,
menu: NSMenu) -> NSMenuItem
menu: NSMenu,
width: CGFloat) -> NSMenuItem
{
let view = CodexAccountSwitcherView(
accounts: display.accounts,
selectedAccountID: display.activeVisibleAccountID,
width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu),
width: width,
onSelect: { [weak self, weak menu] visibleAccountID in
guard let self else { return }
self.handleCodexVisibleAccountSelection(visibleAccountID, menu: menu)
Expand Down Expand Up @@ -928,7 +976,7 @@ extension StatusItemController {
}
for item in cardItems {
guard let view = item.view else { continue }
let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu)
let width = self.renderedMenuWidth(for: menu)
let height = self.menuCardHeight(for: view, width: width)
view.frame = NSRect(
origin: .zero,
Expand Down Expand Up @@ -1287,8 +1335,7 @@ extension StatusItemController {
}

private func refreshHostedSubviewHeights(in menu: NSMenu) {
let enabledProviders = self.store.enabledProvidersForDisplay()
let width = self.menuCardWidth(for: enabledProviders, menu: menu)
let width = self.renderedMenuWidth(for: menu)

for item in menu.items {
guard let view = item.view else { continue }
Expand Down
3 changes: 1 addition & 2 deletions Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ private final class UsageHistoryMenuHostingView<Content: View>: NSHostingView<Co

extension StatusItemController {
@discardableResult
func addUsageHistoryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool {
func addUsageHistoryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool {
guard let submenu = self.makeUsageHistorySubmenu(provider: provider) else { return false }
let width: CGFloat = 310
let item = self.makeMenuCardItem(
HStack(spacing: 0) {
Text("Subscription Utilization")
Expand Down
16 changes: 16 additions & 0 deletions Tests/CodexBarTests/StatusItemControllerMenuTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import CodexBarCore
import Foundation
import Testing
Expand Down Expand Up @@ -149,4 +150,19 @@ struct StatusItemControllerMenuTests {
snapshot: snapshot))
#expect(snapshot.primary?.usedPercent == 10)
}

@Test
@MainActor
func `menu card width stays at base width when menu accessories are present`() {
let shortcutMenu = NSMenu()
let refreshItem = NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "r")
shortcutMenu.addItem(refreshItem)
#expect(ceil(shortcutMenu.size.width) < 310)

let submenuMenu = NSMenu()
let parentItem = NSMenuItem(title: "Session", action: nil, keyEquivalent: "")
parentItem.submenu = NSMenu(title: "Session")
submenuMenu.addItem(parentItem)
#expect(ceil(submenuMenu.size.width) < 310)
}
}
Loading