diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e9fbe09f..bc3f81a68b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ - Doubao: show Agent Plan windows alongside Coding Plan usage for Volcengine AK/SK accounts that subscribe to both products (#2517). Thanks @Astro-Han! - Augment: store session cookies owner-only (0600), atomically publish updates, and repair permissions on legacy files (#2567). - Ollama: direct declined Chrome Keychain access recovery to the provider card's Refresh (⌘R) action instead of the ambiguous manual-cookie path (#2072). -- Menu: switching from a tall Overview to a shorter provider now shrinks the menu instead of stretching the provider-height spacer into a large blank region. +- Menu: merged provider tabs now size to their own content when switching from Overview instead of padding every tab to the tallest provider and leaving large blank regions. - Codex: persist and budget fork-parent discovery so missing parents quiesce between inventory changes instead of sweeping every rollout on each refresh (#2525, #2538). Thanks @xx205, and @Helmi and @kiranmagic7 for the investigation! - Claude: Auto cold boot with Keychain disabled loads without manual refresh (#2494, fixes #2493). Thanks @gmkbenjamin! - Menu: no more stray floating "Refresh" tooltip beside the menu when switching tabs with the cursor over the actions area. diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 7f18e31a42..47313c6fda 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -163,7 +163,6 @@ extension StatusItemController { self.cancelMergedSwitcherSiblingWarmup() } self.resetCompactAccountMenuExpansionStateIfIdle() - self.resetStableMenuHeightSessionFloor() } func forgetClosedMenu(_ menu: NSMenu) { @@ -233,9 +232,6 @@ extension StatusItemController { "populateMenu", breadcrumb: "populateMenu:\(provider?.rawValue ?? "merged")") defer { self.endMenuOperationTrace(trace, menu: menu, provider: provider) } - // LIFO defers: warmup fills sibling caches, card heights finalize, then the - // stable-height pass equalizes provider tabs against the tallest cached tab. - defer { self.applyStableMenuHeightPadding(in: menu) } defer { self.refreshMenuCardHeights(in: menu) } // Re-warm sibling tab caches after every populate of the open merged menu so a // tab switch attaches pre-rendered rows; no-ops for closed or non-merged menus. @@ -772,10 +768,6 @@ extension StatusItemController { { menu.addItem(.separator()) } - if self.shouldMergeIcons, self.store.enabledProvidersForDisplay().count > 1 { - // Sized by `applyStableMenuHeightPadding` so provider tabs share one height. - menu.addItem(self.makeStableMenuHeightSpacerItem()) - } } } diff --git a/Sources/CodexBar/StatusItemController+MenuSwitcherWarmup.swift b/Sources/CodexBar/StatusItemController+MenuSwitcherWarmup.swift index 6fae7599ef..4bbd3a6b52 100644 --- a/Sources/CodexBar/StatusItemController+MenuSwitcherWarmup.swift +++ b/Sources/CodexBar/StatusItemController+MenuSwitcherWarmup.swift @@ -56,9 +56,6 @@ extension StatusItemController { in: menu, enabledProviders: enabledProviders) } - // Freshly warmed tabs carry zero-height spacers; equalize provider-tab - // heights now so the first switch does not resize the menu window. - self.applyStableMenuHeightPadding(in: menu) } private func warmMergedSwitcherContentIfMissing( diff --git a/Sources/CodexBar/StatusItemController+StableMenuHeight.swift b/Sources/CodexBar/StatusItemController+StableMenuHeight.swift deleted file mode 100644 index 7a69b33161..0000000000 --- a/Sources/CodexBar/StatusItemController+StableMenuHeight.swift +++ /dev/null @@ -1,171 +0,0 @@ -import AppKit -import CodexBarCore - -/// Keeps the merged menu's window height stable across provider tab switches. -/// -/// Probe captures (`MenuSwitchFlickerProbe`) show the tab-switch composite is -/// atomic, but tabs of different heights make AppKit resize the menu window on -/// every switch; the moving bottom edge plus the WindowServer backdrop/shadow -/// recompute reads as a flash. Each provider tab carries a zero-height spacer -/// row between the usage content and the trailing action rows; this pass sizes -/// those spacers so every provider tab matches the tallest one. Overview is -/// excluded: it is a different mode and may be far taller. -/// Runtime-measured native menu row metrics. Fixed estimates would drift with -/// OS versions and accessibility text sizes; instead AppKit lays out a scratch -/// menu once per text-size token and we derive exact row/separator heights -/// (menu chrome padding cancels out of the differences). -@MainActor -private enum NativeMenuRowMetrics { - private static var cached: (textScale: Int, row: CGFloat, separator: CGFloat)? - - static func current() -> (row: CGFloat, separator: CGFloat) { - let textScale = StatusItemController.menuCardHeightTextScaleToken() - if let cached, cached.textScale == textScale { - return (cached.row, cached.separator) - } - let probe = NSMenu() - probe.autoenablesItems = false - probe.addItem(NSMenuItem(title: "Row", action: nil, keyEquivalent: "")) - probe.addItem(NSMenuItem(title: "Row", action: nil, keyEquivalent: "")) - let twoRows = probe.size.height - probe.addItem(NSMenuItem(title: "Row", action: nil, keyEquivalent: "")) - probe.addItem(NSMenuItem(title: "Row", action: nil, keyEquivalent: "")) - let fourRows = probe.size.height - probe.insertItem(.separator(), at: 2) - let fourRowsPlusSeparator = probe.size.height - let row = max(1, (fourRows - twoRows) / 2) - let separator = max(1, fourRowsPlusSeparator - fourRows) - self.cached = (textScale, row, separator) - return (row, separator) - } -} - -/// AppKit lays out custom menu rows from their intrinsic size while a menu is tracking. -/// A bare `NSView` only carries a frame hint, so switching from a tall Overview can stretch -/// the provider spacer to fill Overview's old viewport and leave a large blank band. -@MainActor -final class StableMenuHeightSpacerView: NSView { - private var measuredHeight: CGFloat = 0 - - override var intrinsicContentSize: NSSize { - NSSize(width: NSView.noIntrinsicMetric, height: self.measuredHeight) - } - - func applyHeight(_ height: CGFloat) { - let resolvedHeight = max(0, ceil(height)) - guard self.measuredHeight != resolvedHeight || self.frame.height != resolvedHeight else { return } - - self.measuredHeight = resolvedHeight - self.setFrameSize(NSSize(width: 1, height: resolvedHeight)) - self.invalidateIntrinsicContentSize() - self.superview?.layoutSubtreeIfNeeded() - } -} - -extension StatusItemController { - static let stableMenuHeightSpacerID = "stableHeightSpacer" - - func makeStableMenuHeightSpacerItem() -> NSMenuItem { - let item = NSMenuItem() - item.title = "" - item.isEnabled = false - item.representedObject = Self.stableMenuHeightSpacerID - let view = StableMenuHeightSpacerView(frame: NSRect(x: 0, y: 0, width: 1, height: 0)) - item.view = view - return item - } - - /// Equalizes provider-tab content heights: the visible menu's spacer and every - /// cached provider tab's spacer are sized against the tallest tab. Runs after - /// card heights are final for the current populate pass. - /// - /// Known simplification: cached tabs keep the spacer size they were last - /// padded with, so a data tick that shrinks the tallest tab can leave a stale - /// gap until the sibling becomes visible again — a one-frame height nudge at - /// worst, instead of a jump on every switch. - func applyStableMenuHeightPadding(in menu: NSMenu) { - // Any merged-style menu qualifies: only merged menus carry the provider switcher. - guard self.shouldMergeIcons, menu.items.first?.view is ProviderSwitcherView else { return } - guard self.store.enabledProvidersForDisplay().count > 1 else { return } - let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) - guard contentStartIndex > 0 else { return } - - let visibleItems = Array(menu.items[contentStartIndex...]) - let visibleIsProviderTab = self.lastMergedMenuContentSelection.map { $0 != .overview } ?? true - var tabs: [(spacer: NSMenuItem?, contentHeight: CGFloat)] = [] - if visibleIsProviderTab { - tabs.append(self.measureTab(items: visibleItems)) - } - let cacheEntries = self.mergedSwitcherContentCaches[ObjectIdentifier(menu)] ?? [:] - for (selection, entry) in cacheEntries where selection != .overview { - if visibleIsProviderTab, selection == self.lastMergedMenuContentSelection { - continue - } - tabs.append(self.measureTab(items: entry.items)) - } - MenuSwitchFlickerProbe.debugLog( - "padding: visibleProvider=\(visibleIsProviderTab) " + - "selection=\(String(describing: self.lastMergedMenuContentSelection)) " + - "cacheKeys=\(cacheEntries.keys.map { String(describing: $0) }) " + - "tabs=\(tabs.map { "(spacer:\($0.spacer != nil) h:\($0.contentHeight))" })") - guard let contentMax = tabs.map(\.contentHeight).max() else { return } - - // Session floor: on the first populate only the visible tab is measurable - // (sibling caches fill ~120ms later), so without a floor the menu opens - // short and visibly grows once the warmup lands. The floor from the - // previous open pads immediately; within a session the height only grows. - // The floor resets to the true max when the menu closes, so legitimate - // shrinks happen between opens, invisibly. - let widthKey = Int(self.renderedMenuWidth(for: menu).rounded()) - let floorHeight = self.stableMenuHeightSessionFloor[widthKey] ?? 0 - let targetHeight = max(contentMax, floorHeight) - self.stableMenuHeightSessionFloor[widthKey] = targetHeight - self.stableMenuHeightLastContentMax[widthKey] = tabs.count > 1 - ? contentMax - : max(contentMax, self.stableMenuHeightLastContentMax[widthKey] ?? 0) - guard tabs.count > 1 || floorHeight > 0 else { return } - - for tab in tabs { - guard let spacer = tab.spacer, - let spacerView = spacer.view as? StableMenuHeightSpacerView - else { continue } - let padding = max(0, targetHeight - tab.contentHeight) - if abs(spacerView.frame.height - padding) > 0.5 { - MenuSwitchFlickerProbe.debugLog("padding: set spacer \(spacerView.frame.height) -> \(padding)") - spacerView.applyHeight(padding) - } - } - } - - /// Called when the last menu closes: drop the grow-only session floor to the - /// last true content max so the next open can start shorter if data shrank. - func resetStableMenuHeightSessionFloor() { - guard self.openMenus.isEmpty else { return } - for (widthKey, lastMax) in self.stableMenuHeightLastContentMax { - self.stableMenuHeightSessionFloor[widthKey] = lastMax - } - } - - /// Content height excluding the spacer itself, plus the spacer item when present. - /// View-backed rows use their exact frame; native rows and separators use - /// AppKit-measured metrics for the current text size. Internal for test access. - func measureTab(items: [NSMenuItem]) -> (spacer: NSMenuItem?, contentHeight: CGFloat) { - let metrics = NativeMenuRowMetrics.current() - var spacer: NSMenuItem? - var height: CGFloat = 0 - for item in items { - if item.representedObject as? String == Self.stableMenuHeightSpacerID { - spacer = item - continue - } - if item.isSeparatorItem { - height += metrics.separator - } else if let view = item.view { - height += view.frame.height - } else { - height += metrics.row - } - } - return (spacer, height) - } -} diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 40651ad28a..27adac2851 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -277,10 +277,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin /// Debounced pre-build of sibling switcher tabs for flicker-free tab switches. /// A common-modes Timer (not a Task) so it fires during NSMenu tracking. var mergedSwitcherWarmupTimer: Timer? - /// Stable-height padding: grow-only per-session floor and last measured true - /// max, keyed by rounded menu width. See `applyStableMenuHeightPadding`. - var stableMenuHeightSessionFloor: [Int: CGFloat] = [:] - var stableMenuHeightLastContentMax: [Int: CGFloat] = [:] /// Compact multi-account layout: accounts the user expanded to full cards this menu session. var compactAccountExpandedIDs: Set = [] /// Compact multi-account layout: providers whose collapsed healthy tail is revealed this menu session. diff --git a/Tests/CodexBarTests/StatusMenuSwitcherWarmupTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherWarmupTests.swift index f0fadc3b57..e4c175dfd2 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherWarmupTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherWarmupTests.swift @@ -62,50 +62,19 @@ final class StatusMenuSwitcherWarmupTests: XCTestCase { } } - func test_stableHeightPaddingEqualizesProviderTabs() { - let (controller, menu) = self.makeController() - defer { controller.releaseStatusItemsForTesting() } - - controller.warmMergedSwitcherSiblingContent(in: menu) - - // Overview is excluded from equalization by design; compare provider tabs only. - var totals: [CGFloat] = [] - let visibleSelection = controller.lastMergedMenuContentSelection - if let visibleSelection, visibleSelection != .overview { - let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) - let visible = controller.measureTab(items: Array(menu.items[contentStartIndex...])) - totals.append(visible.contentHeight + (visible.spacer?.view?.frame.height ?? 0)) - } - let caches = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] ?? [:] - for (selection, entry) in caches where selection != .overview { - if selection == visibleSelection { - continue - } - let tab = controller.measureTab(items: entry.items) - XCTAssertNotNil(tab.spacer, "provider tab content must carry a stable-height spacer") - totals.append(tab.contentHeight + (tab.spacer?.view?.frame.height ?? 0)) - } - XCTAssertGreaterThan(totals.count, 1) - let reference = totals[0] - for total in totals { - XCTAssertEqual(total, reference, accuracy: 0.5) - } - } - - func test_stableHeightPaddingPublishesSpacerHeightThroughIntrinsicSize() { + func test_warmupDoesNotAddFlexibleProviderPadding() { let (controller, menu) = self.makeController() defer { controller.releaseStatusItemsForTesting() } controller.warmMergedSwitcherSiblingContent(in: menu) let caches = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] ?? [:] - let providerSpacers = caches.compactMap { selection, entry -> StableMenuHeightSpacerView? in - guard selection != .overview else { return nil } - return controller.measureTab(items: entry.items).spacer?.view as? StableMenuHeightSpacerView - } - XCTAssertFalse(providerSpacers.isEmpty) - for spacer in providerSpacers { - XCTAssertEqual(spacer.intrinsicContentSize.height, spacer.frame.height, accuracy: 0.5) + let providerEntries = caches.filter { $0.key != .overview } + XCTAssertFalse(providerEntries.isEmpty) + for (_, entry) in providerEntries { + XCTAssertFalse(entry.items.contains { item in + item.title.isEmpty && item.view?.frame.height == 0 + }, "provider tabs must size to their content instead of carrying a flexible blank row") } }