Skip to content

Commit d57dabf

Browse files
committed
fix: prevent provider switch content flicker
1 parent f556704 commit d57dabf

5 files changed

Lines changed: 166 additions & 43 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 0.34.1 — Unreleased
44

55
- Menu bar: restore native macOS positioning for merged provider dropdowns while preparing current content before AppKit lays out the menu.
6+
- Menu bar: keep cached provider content visible while switching merged tabs so the open menu no longer flickers through an empty state.
67
- Settings: memoize cookie cache lookups behind the "Cached: …" picker labels so opening Settings and switching panes no longer pays a synchronous Keychain read per SwiftUI body evaluation, which froze the Providers pane for seconds (#1471). Thanks @ProspectOre!
78
- Build: resolve packaged binaries from the bin path SwiftPM reports instead of assuming the legacy `.build/<arch>-apple-macosx/<conf>/` layout, so a stale directory left behind by the older build system no longer silently shadows fresh swiftbuild products in `package_app.sh`. Thanks @ProspectOre!
89
- Menu bar: stop the provider-switcher shortcut monitor from killing the menu's event tracking session. Its event-queue peek re-entered the run loop in tracking mode, which could leave a zombie menu on screen that ignored clicks for tens of seconds (beach ball) — most often right after opening the menu or after rapid Cmd-number provider switching, with Settings… the usual victim. Peeks now run in a barren private run-loop mode, start only once the tracking session is pumping, and no longer touch mouse events. Thanks @ProspectOre!

Sources/CodexBar/StatusItemController+MenuReconcile.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,51 @@ extension StatusItemController {
7979
}
8080
}
8181

82+
/// Replaces cached content without first emptying the tracked menu. Compatible item shells
83+
/// stay attached while their payloads swap; only separator or row-count differences cause
84+
/// structural mutations.
85+
func replaceMenuContentKeepingRowsVisible(
86+
_ menu: NSMenu,
87+
fromIndex: Int,
88+
with newItems: [NSMenuItem])
89+
-> [NSMenuItem]
90+
{
91+
guard fromIndex >= 0, fromIndex <= menu.items.count else { return [] }
92+
defer { self.finishReconciledHighlightTracking(in: menu) }
93+
94+
let liveItems = Array(menu.items[fromIndex...])
95+
let liveCount = liveItems.count
96+
let sharedCount = min(liveCount, newItems.count)
97+
var displacedItems: [NSMenuItem] = []
98+
displacedItems.reserveCapacity(liveCount)
99+
for offset in 0..<sharedCount {
100+
let index = fromIndex + offset
101+
let liveItem = liveItems[offset]
102+
let newItem = newItems[offset]
103+
if liveItem.isSeparatorItem == newItem.isSeparatorItem {
104+
if !liveItem.isSeparatorItem {
105+
self.swapMenuItemContents(liveItem, newItem)
106+
}
107+
displacedItems.append(newItem)
108+
} else {
109+
menu.insertItem(newItem, at: index)
110+
menu.removeItem(liveItem)
111+
displacedItems.append(liveItem)
112+
}
113+
}
114+
if newItems.count > liveCount {
115+
for offset in liveCount..<newItems.count {
116+
menu.insertItem(newItems[offset], at: fromIndex + offset)
117+
}
118+
} else if liveCount > newItems.count {
119+
for offset in newItems.count..<liveCount {
120+
menu.removeItem(liveItems[offset])
121+
displacedItems.append(liveItems[offset])
122+
}
123+
}
124+
return displacedItems
125+
}
126+
82127
private func finishReconciledHighlightTracking(in menu: NSMenu) {
83128
let menuKey = ObjectIdentifier(menu)
84129
guard let highlightedItem = self.highlightedMenuItems[menuKey] else { return }
@@ -125,8 +170,23 @@ extension StatusItemController {
125170
liveItem.keyEquivalent = newItem.keyEquivalent
126171
liveItem.keyEquivalentModifierMask = newItem.keyEquivalentModifierMask
127172
liveItem.indentationLevel = newItem.indentationLevel
173+
liveItem.tag = newItem.tag
174+
liveItem.identifier = newItem.identifier
175+
liveItem.isHidden = newItem.isHidden
176+
liveItem.isAlternate = newItem.isAlternate
177+
liveItem.allowsKeyEquivalentWhenHidden = newItem.allowsKeyEquivalentWhenHidden
178+
liveItem.onStateImage = newItem.onStateImage
179+
liveItem.offStateImage = newItem.offStateImage
180+
liveItem.mixedStateImage = newItem.mixedStateImage
128181
if #available(macOS 14.4, *) {
129182
liveItem.subtitle = newItem.subtitle
130183
}
131184
}
185+
186+
private func swapMenuItemContents(_ liveItem: NSMenuItem, _ cachedItem: NSMenuItem) {
187+
let holder = NSMenuItem()
188+
self.updateMenuItemInPlace(holder, from: liveItem)
189+
self.updateMenuItemInPlace(liveItem, from: cachedItem)
190+
self.updateMenuItemInPlace(cachedItem, from: holder)
191+
}
132192
}

Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,34 +32,34 @@ extension StatusItemController {
3232

3333
if isSelectionSwitch,
3434
let outgoingSelection,
35-
self.hasReusableMergedSwitcherContent(
35+
let cachedItems = self.reusableMergedSwitcherContent(
3636
for: context.switcherSelection,
3737
in: menu,
3838
menuWidth: context.menuWidth,
3939
codexAccountDisplay: context.codexAccountDisplay,
4040
tokenAccountDisplay: context.tokenAccountDisplay)
4141
{
42-
// Instant path: the incoming tab reattaches wholesale, so park the outgoing
43-
// items for an equally instant switch-back.
44-
self.cacheVisibleMergedSwitcherContent(
42+
// Park the outgoing payloads for an equally instant switch-back. Compatible
43+
// menu-item shells stay attached, avoiding the empty intermediate layout that
44+
// AppKit can visibly render when the whole content block is removed first.
45+
let outgoingCodexAccountDisplay = self.lastCodexAccountMenuDisplay
46+
let outgoingTokenAccountDisplay = self.lastTokenAccountMenuDisplay
47+
self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection)
48+
let displacedItems = self.replaceMenuContentKeepingRowsVisible(
49+
menu,
50+
fromIndex: contentStartIndex,
51+
with: cachedItems)
52+
self.cacheMergedSwitcherContent(
53+
displacedItems,
4554
in: menu,
4655
selection: outgoingSelection,
47-
contentStartIndex: contentStartIndex,
48-
menuWidth: context.menuWidth)
49-
while menu.items.count > contentStartIndex {
50-
menu.removeItem(at: contentStartIndex)
51-
}
52-
self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection)
53-
if self.addCachedMergedSwitcherContent(
54-
for: context.switcherSelection,
55-
to: menu,
56-
menuWidth: context.menuWidth,
57-
codexAccountDisplay: context.codexAccountDisplay,
58-
tokenAccountDisplay: context.tokenAccountDisplay)
59-
{
60-
return
61-
}
62-
self.addSwitcherScopedMenuContent(into: menu, captureMenu: menu, context: context)
56+
context: MergedSwitcherContentCacheContext(
57+
menuWidth: context.menuWidth,
58+
codexAccountDisplay: outgoingCodexAccountDisplay,
59+
tokenAccountDisplay: outgoingTokenAccountDisplay,
60+
contentVersion: nil))
61+
self.lastCodexAccountMenuDisplay = context.codexAccountDisplay
62+
self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay
6363
self.cacheVisibleMergedSwitcherContent(
6464
in: menu,
6565
selection: context.switcherSelection,

Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ struct CachedMergedSwitcherMenuContent {
2424
}
2525
}
2626

27+
struct MergedSwitcherContentCacheContext {
28+
let menuWidth: CGFloat
29+
let codexAccountDisplay: CodexAccountMenuDisplay?
30+
let tokenAccountDisplay: TokenAccountMenuDisplay?
31+
let contentVersion: Int?
32+
}
33+
2734
extension StatusItemController {
2835
func preservingMergedSwitcherContentCachesDuringInvalidation(_ body: () -> Void) {
2936
let previous = self.preservesMergedSwitcherContentCachesDuringInvalidation
@@ -51,33 +58,48 @@ extension StatusItemController {
5158
guard menu.items.first?.view is ProviderSwitcherView else { return }
5259
guard contentStartIndex < menu.items.count else { return }
5360
let items = Array(menu.items[contentStartIndex...])
61+
self.cacheMergedSwitcherContent(
62+
items,
63+
in: menu,
64+
selection: selection,
65+
context: MergedSwitcherContentCacheContext(
66+
menuWidth: menuWidth,
67+
codexAccountDisplay: self.lastCodexAccountMenuDisplay,
68+
tokenAccountDisplay: self.lastTokenAccountMenuDisplay,
69+
contentVersion: contentVersion))
70+
}
71+
72+
func cacheMergedSwitcherContent(
73+
_ items: [NSMenuItem],
74+
in menu: NSMenu,
75+
selection: ProviderSwitcherSelection,
76+
context: MergedSwitcherContentCacheContext)
77+
{
5478
guard !items.isEmpty else { return }
5579

5680
let entry = CachedMergedSwitcherMenuContent(
57-
requiredMenuContentVersion: contentVersion ??
81+
requiredMenuContentVersion: context.contentVersion ??
5882
self.menuVersions[ObjectIdentifier(menu)] ??
5983
self.latestRequiredMenuRebuildVersion,
60-
menuWidth: menuWidth,
61-
codexAccountDisplay: self.lastCodexAccountMenuDisplay,
62-
tokenAccountDisplay: self.lastTokenAccountMenuDisplay,
84+
menuWidth: context.menuWidth,
85+
codexAccountDisplay: context.codexAccountDisplay,
86+
tokenAccountDisplay: context.tokenAccountDisplay,
6387
localizationSignature: self.lastMenuLocalizationSignature,
6488
items: items)
6589
self.mergedSwitcherContentCaches[ObjectIdentifier(menu), default: [:]][selection] = entry
6690
}
6791

68-
/// Non-consuming variant of `addCachedMergedSwitcherContent`'s lookup: reports whether a
69-
/// reusable entry exists (evicting it when stale) without attaching anything, so callers
70-
/// can choose between reattaching cached content and recycling the outgoing views.
71-
func hasReusableMergedSwitcherContent(
92+
/// Returns a reusable cached content block, evicting stale entries without attaching them.
93+
func reusableMergedSwitcherContent(
7294
for selection: ProviderSwitcherSelection,
7395
in menu: NSMenu,
7496
menuWidth: CGFloat,
7597
codexAccountDisplay: CodexAccountMenuDisplay?,
7698
tokenAccountDisplay: TokenAccountMenuDisplay?)
77-
-> Bool
99+
-> [NSMenuItem]?
78100
{
79101
let key = ObjectIdentifier(menu)
80-
guard let entry = self.mergedSwitcherContentCaches[key]?[selection] else { return false }
102+
guard let entry = self.mergedSwitcherContentCaches[key]?[selection] else { return nil }
81103
guard entry.matches(
82104
requiredMenuContentVersion: self.latestRequiredMenuRebuildVersion,
83105
menuWidth: menuWidth,
@@ -86,9 +108,9 @@ extension StatusItemController {
86108
localizationSignature: self.menuLocalizationSignature())
87109
else {
88110
self.mergedSwitcherContentCaches[key]?.removeValue(forKey: selection)
89-
return false
111+
return nil
90112
}
91-
return true
113+
return entry.items
92114
}
93115

94116
func addCachedMergedSwitcherContent(
@@ -99,22 +121,17 @@ extension StatusItemController {
99121
tokenAccountDisplay: TokenAccountMenuDisplay?)
100122
-> Bool
101123
{
102-
let key = ObjectIdentifier(menu)
103-
guard let entry = self.mergedSwitcherContentCaches[key]?[selection] else { return false }
104-
guard entry.matches(
105-
requiredMenuContentVersion: self.latestRequiredMenuRebuildVersion,
124+
guard let items = self.reusableMergedSwitcherContent(
125+
for: selection,
126+
in: menu,
106127
menuWidth: menuWidth,
107128
codexAccountDisplay: codexAccountDisplay,
108-
tokenAccountDisplay: tokenAccountDisplay,
109-
localizationSignature: self.menuLocalizationSignature())
110-
else {
111-
self.mergedSwitcherContentCaches[key]?.removeValue(forKey: selection)
112-
return false
113-
}
129+
tokenAccountDisplay: tokenAccountDisplay)
130+
else { return false }
114131

115132
self.lastCodexAccountMenuDisplay = codexAccountDisplay
116133
self.lastTokenAccountMenuDisplay = tokenAccountDisplay
117-
for item in entry.items {
134+
for item in items {
118135
menu.addItem(item)
119136
}
120137
return true

Tests/CodexBarTests/MenuCardViewRecyclingTests.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,51 @@ extension StatusMenuTests {
238238
#expect(menu.items[2].title == "New Provider Action")
239239
}
240240

241+
@Test
242+
func `cached provider content swap preserves both item sets for switch back`() {
243+
let settings = self.makeSettings()
244+
settings.statusChecksEnabled = false
245+
let controller = self.makeRecyclingController(settings: settings)
246+
defer { controller.releaseStatusItemsForTesting() }
247+
248+
let switcher = NSMenuItem(title: "Switcher", action: nil, keyEquivalent: "")
249+
let outgoing = [
250+
NSMenuItem(title: "Overview Card", action: nil, keyEquivalent: ""),
251+
NSMenuItem.separator(),
252+
NSMenuItem(title: "Overview Action", action: nil, keyEquivalent: ""),
253+
]
254+
let incoming = [
255+
NSMenuItem(title: "Codex Card", action: nil, keyEquivalent: ""),
256+
NSMenuItem.separator(),
257+
NSMenuItem(title: "Codex Usage", action: nil, keyEquivalent: ""),
258+
NSMenuItem(title: "Codex Settings", action: nil, keyEquivalent: ""),
259+
]
260+
let menu = NSMenu()
261+
menu.addItem(switcher)
262+
outgoing.forEach(menu.addItem)
263+
264+
let displacedOutgoing = controller.replaceMenuContentKeepingRowsVisible(
265+
menu,
266+
fromIndex: 1,
267+
with: incoming)
268+
269+
#expect(menu.items.first === switcher)
270+
#expect(menu.items.dropFirst().map(\.title) == ["Codex Card", "", "Codex Usage", "Codex Settings"])
271+
#expect(Array(menu.items[1...3]).map(ObjectIdentifier.init) == outgoing.map(ObjectIdentifier.init))
272+
#expect(displacedOutgoing.map(ObjectIdentifier.init) == incoming.prefix(3).map(ObjectIdentifier.init))
273+
#expect(displacedOutgoing.map(\.title) == ["Overview Card", "", "Overview Action"])
274+
275+
let displacedIncoming = controller.replaceMenuContentKeepingRowsVisible(
276+
menu,
277+
fromIndex: 1,
278+
with: displacedOutgoing)
279+
280+
#expect(Array(menu.items.dropFirst()).map(ObjectIdentifier.init) == outgoing.map(ObjectIdentifier.init))
281+
#expect(displacedIncoming.map(ObjectIdentifier.init) == incoming.map(ObjectIdentifier.init))
282+
#expect(displacedIncoming.allSatisfy { $0.menu == nil })
283+
#expect(displacedIncoming.map(\.title) == ["Codex Card", "", "Codex Usage", "Codex Settings"])
284+
}
285+
241286
@Test
242287
func `reconcile preserves highlight on a retained custom action row`() {
243288
let settings = self.makeSettings()

0 commit comments

Comments
 (0)