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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra!
- Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405!
- Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325).
- Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl!
- Development: disable Keychain access for unbundled executables to avoid repeated password prompts while preserving packaged app behavior (#1271). Thanks @Yuxin-Qiao!
- Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner!
- Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore!
Expand Down
132 changes: 43 additions & 89 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -387,88 +387,15 @@ extension StatusItemController {
return reusableRows
}

/// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact.
private struct MenuUpdateContext {
let provider: UsageProvider?
let currentProvider: UsageProvider
let switcherSelection: ProviderSwitcherSelection
let menuWidth: CGFloat
let codexAccountDisplay: CodexAccountMenuDisplay?
let tokenAccountDisplay: TokenAccountMenuDisplay?
let openAIContext: OpenAIWebContext
let descriptor: MenuDescriptor
}

/// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact.
private func updateMenuContentPreservingSwitcher(
_ menu: NSMenu,
context: MenuUpdateContext)
{
self.performMenuMutationWithoutAnimation {
let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu)
if let switcherView = menu.items.first?.view as? ProviderSwitcherView {
switcherView.updateSelection(context.switcherSelection)
switcherView.updateQuotaIndicators()
}
if let outgoingSelection = self.lastMergedMenuContentSelection,
outgoingSelection != context.switcherSelection
{
self.cacheVisibleMergedSwitcherContent(
in: menu,
selection: outgoingSelection,
contentStartIndex: contentStartIndex,
menuWidth: context.menuWidth)
}
while menu.items.count > contentStartIndex {
menu.removeItem(at: contentStartIndex)
}

let enabledProviders = self.store.enabledProvidersForDisplay()
self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection)
if self.addCachedMergedSwitcherContent(
for: context.switcherSelection,
to: menu,
menuWidth: context.menuWidth,
codexAccountDisplay: context.codexAccountDisplay,
tokenAccountDisplay: context.tokenAccountDisplay)
{
return
}
self.addCodexAccountSwitcherIfNeeded(
to: menu,
display: context.codexAccountDisplay,
width: context.menuWidth)
self.lastCodexAccountMenuDisplay = context.codexAccountDisplay
self.addTokenAccountSwitcherIfNeeded(
to: menu,
display: context.tokenAccountDisplay,
width: context.menuWidth)
self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay

let menuContext = MenuCardContext(
currentProvider: context.currentProvider,
selectedProvider: context.provider,
menuWidth: context.menuWidth,
codexAccountDisplay: context.codexAccountDisplay,
tokenAccountDisplay: context.tokenAccountDisplay,
openAIContext: context.openAIContext)
self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection)
self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth)
self.cacheVisibleMergedSwitcherContent(
in: menu,
selection: context.switcherSelection,
contentStartIndex: contentStartIndex,
menuWidth: context.menuWidth,
contentVersion: self.menuContentVersion)
}
}

private func rebuildMenuContent(
_ menu: NSMenu,
context: MenuRebuildContext)
{
self.performMenuMutationWithoutAnimation {
let displacedSelection = self.lastMergedMenuContentSelection
self.lastMergedMenuContentSelection = nil
self.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: displacedSelection)
defer { self.clearMenuCardViewRecyclePool() }
menu.removeAllItems()
let contentSelection = context.switcherSelection ?? .provider(context.currentProvider)
self.addProviderSwitcherIfNeeded(
Expand Down Expand Up @@ -567,16 +494,32 @@ extension StatusItemController {
menu.addItem(.separator())
}

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

private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?, width: CGFloat) {
func addCodexAccountSwitcherIfNeeded(
to menu: NSMenu,
display: CodexAccountMenuDisplay?,
width: CGFloat,
captureMenu: NSMenu? = nil)
{
guard let display, display.showSwitcher else { return }
let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu, width: width)
let switcherItem = self.makeCodexAccountSwitcherItem(
display: display,
menu: captureMenu ?? menu,
width: width)
menu.addItem(switcherItem)
menu.addItem(.separator())
}
Expand All @@ -585,8 +528,12 @@ extension StatusItemController {
private func addOverviewRows(
to menu: NSMenu,
enabledProviders: [UsageProvider],
menuWidth: CGFloat) -> Bool
menuWidth: CGFloat,
captureMenu: NSMenu? = nil) -> Bool
{
// Rows may be built into a detached scratch menu for in-place reconciliation;
// interaction closures must always reference the live menu they end up serving.
let interactionMenu = captureMenu ?? menu
let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders(
activeProviders: enabledProviders)
let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders
Expand Down Expand Up @@ -616,9 +563,9 @@ extension StatusItemController {
section: "overview",
additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]),
submenu: submenu,
onClick: { [weak self, weak menu] in
guard let self, let menu else { return }
self.selectOverviewProvider(row.provider, menu: menu)
onClick: { [weak self, weak interactionMenu] in
guard let self, let interactionMenu else { return }
self.selectOverviewProvider(row.provider, menu: interactionMenu)
})
if submenu == nil {
// Keep plain rows wired for keyboard activation and accessibility action paths.
Expand Down Expand Up @@ -768,17 +715,19 @@ extension StatusItemController {
menu.addItem(.separator())
}

private func addPrimaryMenuContent(
func addPrimaryMenuContent(
to menu: NSMenu,
context: MenuCardContext,
switcherSelection: ProviderSwitcherSelection)
switcherSelection: ProviderSwitcherSelection,
captureMenu: NSMenu? = nil)
{
if switcherSelection == .overview {
let enabledProviders = self.store.enabledProvidersForDisplay()
if self.addOverviewRows(
to: menu,
enabledProviders: enabledProviders,
menuWidth: context.menuWidth)
menuWidth: context.menuWidth,
captureMenu: captureMenu)
{
menu.addItem(.separator())
} else {
Expand Down Expand Up @@ -809,7 +758,12 @@ extension StatusItemController {
}
}

func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) {
func addActionableSections(
_ sections: [MenuDescriptor.Section],
to menu: NSMenu,
width: CGFloat,
captureMenu: NSMenu? = nil)
{
let actionableSections = sections.filter { section in
section.entries.contains { entry in
if case .action = entry { return true }
Expand Down Expand Up @@ -843,7 +797,7 @@ extension StatusItemController {
menu.addItem(self.makePersistentMenuActionItem(
title: localizedTitle,
action: action,
menu: menu,
menu: captureMenu ?? menu,
width: width))
continue
}
Expand Down
37 changes: 27 additions & 10 deletions Sources/CodexBar/StatusItemController+MenuCardItems.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ extension StatusItemController {
}
}

func makeMenuCardItem(
_ view: some View,
func makeMenuCardItem<CardContent: View>(
_ view: CardContent,
id: String,
width: CGFloat,
heightCacheScope: String? = nil,
Expand All @@ -43,16 +43,33 @@ extension StatusItemController {
return item
}

let highlightState = MenuCardHighlightState()
let wrapped = MenuCardSectionContainerView(
highlightState: highlightState,
showsSubmenuIndicator: submenu != nil,
submenuIndicatorAlignment: submenuIndicatorAlignment,
submenuIndicatorTopPadding: submenuIndicatorTopPadding)
let hosting: MenuCardItemHostingView<MenuCardSectionContainerView<CardContent>>
if let recycled = self.takeRecyclableMenuCardView(
for: id,
as: MenuCardItemHostingView<MenuCardSectionContainerView<CardContent>>.self)
{
view
let wrapped = MenuCardSectionContainerView(
highlightState: recycled.highlightState,
showsSubmenuIndicator: submenu != nil,
submenuIndicatorAlignment: submenuIndicatorAlignment,
submenuIndicatorTopPadding: submenuIndicatorTopPadding)
{
view
}
recycled.prepareForReuse(rootView: wrapped, onClick: onClick)
hosting = recycled
} else {
let highlightState = MenuCardHighlightState()
let wrapped = MenuCardSectionContainerView(
highlightState: highlightState,
showsSubmenuIndicator: submenu != nil,
submenuIndicatorAlignment: submenuIndicatorAlignment,
submenuIndicatorTopPadding: submenuIndicatorTopPadding)
{
view
}
hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick)
}
let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick)
let height = self.cachedMenuCardHeight(
for: id,
scope: heightCacheScope ?? id,
Expand Down
69 changes: 69 additions & 0 deletions Sources/CodexBar/StatusItemController+MenuCardRecycling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import AppKit

extension StatusItemController {
/// Collects the card hosting views of items the current populate pass is about to discard
/// so `makeMenuCardItem` can reuse them for cards with the same identifier (or, failing
/// that, the same content type) instead of building fresh hosting views.
///
/// Safety: live menu items can alias one merged-switcher cache entry — the one for the
/// selection currently displayed, re-cached at the end of every populate. Consuming that
/// entry up front (`displacedSelection`) guarantees no cache entry can still reference a
/// harvested view; entries for other selections only hold items already detached from the
/// menu. Harvested views are detached from their outgoing items; whatever the pass does
/// not consume is released by `clearMenuCardViewRecyclePool`.
func harvestRecyclableMenuCardViews(
in menu: NSMenu,
fromIndex: Int,
displacedSelection: ProviderSwitcherSelection?,
preserveHighlightedItem: Bool = false)
{
self.menuCardViewRecyclePool.removeAll(keepingCapacity: true)
let menuKey = ObjectIdentifier(menu)
if let displacedSelection {
self.mergedSwitcherContentCaches[menuKey]?.removeValue(forKey: displacedSelection)
}
guard Self.menuCardRenderingEnabled else { return }
guard fromIndex >= 0, fromIndex < menu.items.count else { return }
for item in menu.items[fromIndex...] {
guard let id = item.representedObject as? String else { continue }
guard let view = item.view, view is any MenuCardMeasuring else { continue }
guard self.menuCardViewRecyclePool[id] == nil else { continue }
// Unhighlight before detaching: the highlight tracker unwinds through the
// outgoing item's `view`, which is about to become nil, so a recycled view
// would otherwise re-attach visibly highlighted with no path to clear it.
if self.highlightedMenuItems[menuKey] === item {
if !preserveHighlightedItem {
self.highlightedMenuItems.removeValue(forKey: menuKey)
}
}
(view as? MenuCardHighlighting)?.setHighlighted(false)
item.view = nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset highlight before detaching recycled views

When an open menu is rebuilt while a card is highlighted, this detaches the view from the NSMenuItem that highlightedMenuItems still stores. If AppKit subsequently sends menu(_:willHighlight:) with nil or a different rebuilt item, the old item has no view, so the existing code cannot call setHighlighted(false) and the recycled hosting view can remain visibly highlighted on the wrong/no row. Clear the highlight or update highlightedMenuItems before moving the view into the recycle pool.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed real: both unhighlight paths (menu(_:willHighlight:) and the menuDidClose cleanup) unwind through the tracked item's view, which harvesting nils out, so a card highlighted at rebuild time would re-attach with stale highlight rendering and no path to clear it. On current main this can't surface because the discarded view dies with the item; with recycling the state survives into the visible menu.

Fixed in 2f5cf69: harvestRecyclableMenuCardViews now calls setHighlighted(false) on the view and drops the menu's highlightedMenuItems entry when it strips the tracked item, before pooling. Behavior matches main's rebuild semantics (fresh content starts unhighlighted; the next mouse-move re-highlights via willHighlight). Added a regression test: harvesting a highlighted card clears its highlight and tracking entry. Full suite + make check green.

self.menuCardViewRecyclePool[id] = view
}
}

/// Pops a pool entry adoptable as `ViewType`: the same card identifier when its view
/// matches, otherwise the first type-compatible leftover. The fallback is what makes
/// provider switches cheap — a different provider's card with a different identifier but
/// the same SwiftUI content type (for example two providers' usage cards) is repainted
/// in place instead of being rebuilt.
func takeRecyclableMenuCardView<ViewType: NSView>(for id: String, as type: ViewType.Type) -> ViewType? {
if let candidate = self.menuCardViewRecyclePool.removeValue(forKey: id) {
if let adopted = candidate as? ViewType {
return adopted
}
// A same-id view of an incompatible shape can never be adopted later in this
// pass; dropping it restores the build-fresh behavior.
return nil
}
guard let match = self.menuCardViewRecyclePool.first(where: { $0.value is ViewType }) else {
return nil
}
self.menuCardViewRecyclePool.removeValue(forKey: match.key)
return match.value as? ViewType
}

func clearMenuCardViewRecyclePool() {
self.menuCardViewRecyclePool.removeAll(keepingCapacity: true)
}
}
28 changes: 23 additions & 5 deletions Sources/CodexBar/StatusItemController+MenuPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ final class MenuHostingView<Content: View>: NSHostingView<Content> {

@MainActor
final class MenuCardItemHostingView<Content: View>: NSHostingView<Content>, MenuCardHighlighting, MenuCardMeasuring {
private let highlightState: MenuCardHighlightState
private let onClick: (() -> Void)?
let highlightState: MenuCardHighlightState
private var onClick: (() -> Void)?
private var hasClickRecognizer = false

override var allowsVibrancy: Bool {
true
Expand All @@ -100,12 +101,29 @@ final class MenuCardItemHostingView<Content: View>: NSHostingView<Content>, Menu
self.onClick = onClick
super.init(rootView: rootView)
if onClick != nil {
let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:)))
recognizer.buttonMask = 0x1
self.addGestureRecognizer(recognizer)
self.installClickRecognizer()
}
}

/// Reuses this hosting view for a rebuilt card with the same identity: the replaced
/// `rootView` is diffed in place by SwiftUI instead of tearing down and recreating the
/// hosting view and its graph. Callers must construct `rootView` around this view's own
/// `highlightState` so menu hover highlighting keeps driving the rendered content.
func prepareForReuse(rootView: Content, onClick: (() -> Void)?) {
self.rootView = rootView
self.onClick = onClick
if onClick != nil, !self.hasClickRecognizer {
self.installClickRecognizer()
}
}

private func installClickRecognizer() {
let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:)))
recognizer.buttonMask = 0x1
self.addGestureRecognizer(recognizer)
self.hasClickRecognizer = true
}

required init(rootView: Content) {
self.highlightState = MenuCardHighlightState()
self.onClick = nil
Expand Down
Loading