import AppKit struct ManualRefreshViewportRestoreRequest { let generation: Int let menuInteractionGeneration: Int let switcherSelection: ProviderSwitcherSelection? } struct MenuViewportGeometry: Equatable { let documentID: ObjectIdentifier let clipID: ObjectIdentifier let documentSize: CGSize let documentIsFlipped: Bool let clipSize: CGSize let clipOrigin: CGPoint } enum MenuViewportGeometryTransition: Equatable { case unchanged case layout case movement } private struct MenuViewportOriginRange { private(set) var minimumX: CGFloat private(set) var maximumX: CGFloat private(set) var minimumY: CGFloat private(set) var maximumY: CGFloat init(baseline: CGPoint, current: CGPoint) { self.minimumX = min(baseline.x, current.x) self.maximumX = max(baseline.x, current.x) self.minimumY = min(baseline.y, current.y) self.maximumY = max(baseline.y, current.y) } mutating func include(_ origin: CGPoint) { self.minimumX = min(self.minimumX, origin.x) self.maximumX = max(self.maximumX, origin.x) self.minimumY = min(self.minimumY, origin.y) self.maximumY = max(self.maximumY, origin.y) } func exceeds(_ tolerance: CGFloat) -> Bool { self.maximumX - self.minimumX > tolerance || self.maximumY - self.minimumY > tolerance } } @MainActor final class ManualRefreshViewportMovementTracker: NSObject { private weak var scrollView: NSScrollView? private weak var clipView: NSClipView? private weak var documentView: NSView? private let originalPostsBoundsChangedNotifications: Bool private let originalClipPostsFrameChangedNotifications: Bool private let originalDocumentPostsFrameChangedNotifications: Bool private var baselineGeometry: MenuViewportGeometry? private var pendingOriginRange: MenuViewportOriginRange? private var afterSettleOperations: [@MainActor () -> Void] = [] private var settleScheduled = false private var permitsPostLayoutTopCorrection = false private var isActive = true private(set) var observedMovement = false init(scrollView: NSScrollView) { let clipView = scrollView.contentView let documentView = scrollView.documentView self.scrollView = scrollView self.clipView = clipView self.documentView = documentView self.originalPostsBoundsChangedNotifications = clipView.postsBoundsChangedNotifications self.originalClipPostsFrameChangedNotifications = clipView.postsFrameChangedNotifications self.originalDocumentPostsFrameChangedNotifications = documentView?.postsFrameChangedNotifications ?? false self.baselineGeometry = nil self.pendingOriginRange = nil super.init() clipView.postsBoundsChangedNotifications = true clipView.postsFrameChangedNotifications = true documentView?.postsFrameChangedNotifications = true self.baselineGeometry = StatusItemController.menuViewportGeometry(in: scrollView) NotificationCenter.default.addObserver( self, selector: #selector(self.viewportGeometryDidChange(_:)), name: NSView.boundsDidChangeNotification, object: clipView) NotificationCenter.default.addObserver( self, selector: #selector(self.viewportGeometryDidChange(_:)), name: NSView.frameDidChangeNotification, object: clipView) if let documentView { NotificationCenter.default.addObserver( self, selector: #selector(self.viewportGeometryDidChange(_:)), name: NSView.frameDidChangeNotification, object: documentView) } } deinit { NotificationCenter.default.removeObserver(self) } func stop() { guard self.isActive else { return } self.isActive = false self.clipView?.postsBoundsChangedNotifications = self.originalPostsBoundsChangedNotifications self.clipView?.postsFrameChangedNotifications = self.originalClipPostsFrameChangedNotifications self.documentView?.postsFrameChangedNotifications = self.originalDocumentPostsFrameChangedNotifications self.settleScheduled = false self.permitsPostLayoutTopCorrection = false self.pendingOriginRange = nil self.afterSettleOperations.removeAll(keepingCapacity: false) self.documentView = nil self.clipView = nil self.scrollView = nil } func isTracking(_ scrollView: NSScrollView) -> Bool { self.scrollView === scrollView && self.clipView === scrollView.contentView && self.documentView === scrollView.documentView } /// Make settled refresh geometry the baseline for the short completion-to-delivery window. /// Callers must enter through `afterPendingGeometrySettles` so stale AppKit geometry is never sampled. func rebaseAfterRefreshLayout() { guard !self.observedMovement, let scrollView = self.scrollView else { return } self.baselineGeometry = StatusItemController.menuViewportGeometry(in: scrollView) self.pendingOriginRange = nil } func afterPendingGeometrySettles(_ operation: @escaping @MainActor () -> Void) { guard self.isActive else { return } guard self.settleScheduled else { operation() return } self.afterSettleOperations.append(operation) } func settlePendingGeometryChanges() { self.settleScheduled = false if self.isActive, !self.observedMovement, let scrollView = self.scrollView, let current = StatusItemController.menuViewportGeometry(in: scrollView) { if let baselineGeometry = self.baselineGeometry { let pendingMovement = self.pendingOriginRange?.exceeds(1) == true switch StatusItemController.menuViewportGeometryTransition(from: baselineGeometry, to: current) { case .unchanged: if pendingMovement { if self.consumePostLayoutTopCorrectionIfNeeded(current) { self.baselineGeometry = current } else { self.observedMovement = true } } // Otherwise keep the original baseline so fractional scroll deltas accumulate. case .layout: self.baselineGeometry = current // AppKit can publish the new document frame one run-loop pass before its // automatic reset to the menu's top. Only that no-op restore target is safe // to absorb; an arbitrary next origin is newer user movement. self.permitsPostLayoutTopCorrection = !pendingMovement case .movement: if self.consumePostLayoutTopCorrectionIfNeeded(current) { self.baselineGeometry = current } else { self.observedMovement = true } } } else { self.baselineGeometry = current } self.pendingOriginRange = nil } let operations = self.afterSettleOperations self.afterSettleOperations.removeAll(keepingCapacity: false) for operation in operations where self.isActive { operation() } } private func consumePostLayoutTopCorrectionIfNeeded(_ geometry: MenuViewportGeometry) -> Bool { guard self.permitsPostLayoutTopCorrection else { return false } self.permitsPostLayoutTopCorrection = false return StatusItemController.menuViewportGeometryIsAtTop(geometry) } @objc private func viewportGeometryDidChange(_: Notification) { guard self.isActive, !self.observedMovement else { return } if let origin = self.scrollView?.contentView.bounds.origin { if self.pendingOriginRange == nil { self.pendingOriginRange = MenuViewportOriginRange( baseline: self.baselineGeometry?.clipOrigin ?? origin, current: origin) } else { self.pendingOriginRange?.include(origin) } } guard !self.settleScheduled else { return } self.settleScheduled = true ProviderSwitcherTrackingRunLoopScheduler.schedule { [weak self] in self?.settlePendingGeometryChanges() } } } private struct ManualRefreshViewportMovementTracking { let generation: Int let tracker: ManualRefreshViewportMovementTracker } @MainActor final class ManualRefreshViewportRestoreState { var deferredUntilRebuild: [ObjectIdentifier: ManualRefreshViewportRestoreRequest] = [:] private var movementTrackers: [ObjectIdentifier: ManualRefreshViewportMovementTracking] = [:] func startMovementTracking( for key: ObjectIdentifier, generation: Int, scrollView: NSScrollView) { self.stopMovementTracking(for: key) self.movementTrackers[key] = ManualRefreshViewportMovementTracking( generation: generation, tracker: ManualRefreshViewportMovementTracker(scrollView: scrollView)) } func prepareForCompletedRefreshLayout( for key: ObjectIdentifier, generation: Int, scrollView: NSScrollView, completion: @escaping @MainActor () -> Void) { self.prepareMovementTracking( for: key, generation: generation, scrollView: scrollView, rebaseAfterLayout: true, completion: completion) } func prepareForDelivery( for key: ObjectIdentifier, generation: Int, scrollView: NSScrollView, completion: @escaping @MainActor () -> Void) { self.prepareMovementTracking( for: key, generation: generation, scrollView: scrollView, rebaseAfterLayout: false, completion: completion) } func afterMovementSettles( for key: ObjectIdentifier, generation: Int, operation: @escaping @MainActor () -> Void) { guard let tracking = self.movementTrackers[key], tracking.generation == generation else { operation() return } let tracker = tracking.tracker tracker.afterPendingGeometrySettles { [weak self, weak tracker] in guard let self, let tracker, let current = self.movementTrackers[key], current.generation == generation, current.tracker === tracker else { return } operation() } } func observedMovement(for key: ObjectIdentifier, generation: Int) -> Bool { guard let tracking = self.movementTrackers[key], tracking.generation == generation else { return false } return tracking.tracker.observedMovement } func stopMovementTracking(for key: ObjectIdentifier, generation: Int? = nil) { guard let tracking = self.movementTrackers[key], generation == nil || tracking.generation == generation else { return } tracking.tracker.stop() self.movementTrackers.removeValue(forKey: key) } func stopAllMovementTracking() { for tracking in self.movementTrackers.values { tracking.tracker.stop() } self.movementTrackers.removeAll(keepingCapacity: false) } private func prepareMovementTracking( for key: ObjectIdentifier, generation: Int, scrollView: NSScrollView, rebaseAfterLayout: Bool, completion: @escaping @MainActor () -> Void) { guard let tracking = self.movementTrackers[key] else { self.startMovementTracking(for: key, generation: generation, scrollView: scrollView) completion() return } guard tracking.generation == generation else { // A newer overlapping provider refresh owns this menu's tracker. Let the caller's // context check discard the stale completion without erasing newer movement. completion() return } let tracker = tracking.tracker tracker.afterPendingGeometrySettles { [weak self, weak tracker] in guard let self, let tracker, let current = self.movementTrackers[key], current.generation == generation, current.tracker === tracker else { return } if !tracker.observedMovement { if tracker.isTracking(scrollView) { if rebaseAfterLayout { tracker.rebaseAfterRefreshLayout() } } else { self.startMovementTracking(for: key, generation: generation, scrollView: scrollView) } } completion() } } #if DEBUG var testOperation: (@MainActor () async -> Void)? var testObserver: (@MainActor (NSMenu) -> Void)? var testScheduler: ((@escaping @MainActor () -> Void) -> Void)? #endif } extension StatusItemController { /// A user-initiated manual refresh reconciles the tracked menu in place, and the row /// geometry and AppKit scroll state it changes can leave the private menu viewport anchored /// mid-list with no way back to the top short of closing and reopening the menu. Arm a token /// before refreshing so a close and reopen cannot transfer the restore to a new tracking /// session. Background refreshes never enter this path and therefore never move the viewport. func armManualRefreshViewportRestoreRequests( originatingMenuID: ObjectIdentifier?, originatingMenuInteractionGeneration: Int?) -> [ObjectIdentifier: ManualRefreshViewportRestoreRequest] { let candidates: [(ObjectIdentifier, NSMenu)] if let originatingMenuID { guard let menu = self.openMenus[originatingMenuID] else { return [:] } candidates = [(originatingMenuID, menu)] } else { candidates = Array(self.openMenus) } var requests: [ObjectIdentifier: ManualRefreshViewportRestoreRequest] = [:] for (key, menu) in candidates where menu.supermenu == nil && !self.isHostedSubviewMenu(menu) { guard let menuInteractionGeneration = self.menuSession.menuInteractionGeneration(for: key) else { continue } if key == originatingMenuID, let originatingMenuInteractionGeneration, menuInteractionGeneration != originatingMenuInteractionGeneration { continue } self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key) let generation = self.menuSession.armViewportRestore(key) if let scrollView = Self.attachedMenuScrollView(in: menu) { self.manualRefreshViewportRestoreState.startMovementTracking( for: key, generation: generation, scrollView: scrollView) } requests[key] = ManualRefreshViewportRestoreRequest( generation: generation, menuInteractionGeneration: menuInteractionGeneration, switcherSelection: self.viewportRestoreSwitcherSelection(for: menu)) } return requests } /// A completed manual refresh updates live card content without rebuilding the tracked /// parent menu. Restore on AppKit's tracking run loop after that live layout settles. The /// exact token prevents an older completion from consuming a newer refresh or menu session. func scheduleCompletedManualRefreshViewportRestore( _ requests: [ObjectIdentifier: ManualRefreshViewportRestoreRequest]) { for (key, request) in requests { guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key), let menu = self.openMenus[key] else { self.cancelManualRefreshViewportRestoreRequest(request, for: key) continue } let completion: @MainActor () -> Void = { [weak self, weak menu] in guard let self else { return } guard let menu else { self.cancelManualRefreshViewportRestoreRequest(request, for: key) return } self.continueSchedulingCompletedManualRefreshViewportRestore( request, for: key, menu: menu) } if let scrollView = Self.attachedMenuScrollView(in: menu) { self.manualRefreshViewportRestoreState.prepareForCompletedRefreshLayout( for: key, generation: request.generation, scrollView: scrollView, completion: completion) } else { completion() } } } private func continueSchedulingCompletedManualRefreshViewportRestore( _ request: ManualRefreshViewportRestoreRequest, for key: ObjectIdentifier, menu: NSMenu) { guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key), !self.hasPreparedForAppShutdown, self.openMenus[key] === menu, ObjectIdentifier(menu) == key, menu.supermenu == nil, !self.isHostedSubviewMenu(menu), request.switcherSelection == self.viewportRestoreSwitcherSelection(for: menu), self.menuNeedsRefresh(menu), !self.manualRefreshViewportRestoreState.observedMovement( for: key, generation: request.generation), !self.hasOpenNonHostedChildMenu() else { self.cancelManualRefreshViewportRestoreRequest(request, for: key) return } if self.hasOpenHostedSubviewMenu() || self.parentMenuRebuildPendingAfterHostedSubviewClose || self.openMenuRebuildRequests.tokens[key] != nil { self.manualRefreshViewportRestoreState.deferredUntilRebuild[key] = request return } guard !self.hasMenuItemHighlightedForViewportRestore(in: menu) else { self.cancelManualRefreshViewportRestoreRequest(request, for: key) return } self.scheduleManualRefreshViewportRestore(request, for: menu) } func scheduleDeferredManualRefreshViewportRestoreAfterRebuild(for menu: NSMenu) { let key = ObjectIdentifier(menu) guard let request = self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key) else { return } let completion: @MainActor () -> Void = { [weak self, weak menu] in guard let self else { return } guard let menu else { self.cancelManualRefreshViewportRestoreRequest(request, for: key) return } self.continueSchedulingDeferredManualRefreshViewportRestore( request, for: key, menu: menu) } if let scrollView = Self.attachedMenuScrollView(in: menu) { self.manualRefreshViewportRestoreState.prepareForDelivery( for: key, generation: request.generation, scrollView: scrollView, completion: completion) } else { completion() } } private func continueSchedulingDeferredManualRefreshViewportRestore( _ request: ManualRefreshViewportRestoreRequest, for key: ObjectIdentifier, menu: NSMenu) { guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key), !self.hasPreparedForAppShutdown, self.openMenus[key] === menu, !self.hasOpenNonHostedChildMenu(), !self.hasOpenHostedSubviewMenu(), !self.hasMenuItemHighlightedForViewportRestore(in: menu), !self.manualRefreshViewportRestoreState.observedMovement( for: key, generation: request.generation), request.switcherSelection == self.viewportRestoreSwitcherSelection(for: menu) else { self.cancelManualRefreshViewportRestoreRequest(request, for: key) return } self.scheduleManualRefreshViewportRestore(request, for: menu) } private func scheduleManualRefreshViewportRestore( _ request: ManualRefreshViewportRestoreRequest, for menu: NSMenu) { let key = ObjectIdentifier(menu) let delivery: @MainActor () -> Void = { [weak self, weak menu] in guard let self else { return } guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key) else { self.cancelManualRefreshViewportRestoreRequest(request, for: key) return } guard !self.hasPreparedForAppShutdown, let menu, self.openMenus[key] === menu, request.switcherSelection == self.viewportRestoreSwitcherSelection(for: menu) else { self.cancelManualRefreshViewportRestoreRequest(request, for: key) return } guard !self.hasOpenNonHostedChildMenu() else { self.cancelManualRefreshViewportRestoreRequest(request, for: key) return } let menuIsDirty = self.menuNeedsRefresh(menu) let parentRebuildPending = self.openMenuRebuildRequests.tokens[key] != nil || (self.parentMenuRebuildPendingAfterHostedSubviewClose && menuIsDirty) if self.hasOpenHostedSubviewMenu() { guard menuIsDirty || parentRebuildPending else { self.cancelManualRefreshViewportRestoreRequest(request, for: key) return } self.manualRefreshViewportRestoreState.deferredUntilRebuild[key] = request return } if parentRebuildPending { self.manualRefreshViewportRestoreState.deferredUntilRebuild[key] = request return } guard !self.hasMenuItemHighlightedForViewportRestore(in: menu), !self.manualRefreshViewportRestoreState.observedMovement( for: key, generation: request.generation) else { self.cancelManualRefreshViewportRestoreRequest(request, for: key) return } guard self.menuSession.consumeViewportRestore(key, generation: request.generation) else { return } self.manualRefreshViewportRestoreState.stopMovementTracking( for: key, generation: request.generation) self.restoreMenuViewportToTop(menu) } let operation: @MainActor () -> Void = { [weak self] in self?.manualRefreshViewportRestoreState.afterMovementSettles( for: key, generation: request.generation, operation: delivery) } #if DEBUG if let scheduler = self._test_menuViewportRestoreScheduler { scheduler(operation) } else { ProviderSwitcherTrackingRunLoopScheduler.schedule(operation) } #else ProviderSwitcherTrackingRunLoopScheduler.schedule(operation) #endif } func cancelManualRefreshViewportRestore(for key: ObjectIdentifier) { self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key) self.manualRefreshViewportRestoreState.stopMovementTracking(for: key) self.menuSession.cancelViewportRestore(key) } private func cancelManualRefreshViewportRestoreRequest( _ request: ManualRefreshViewportRestoreRequest, for key: ObjectIdentifier) { if self.manualRefreshViewportRestoreState.deferredUntilRebuild[key]?.generation == request.generation { self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key) } self.manualRefreshViewportRestoreState.stopMovementTracking( for: key, generation: request.generation) self.menuSession.consumeViewportRestore(key, generation: request.generation) } func cancelManualRefreshViewportRestoreRequests( _ requests: [ObjectIdentifier: ManualRefreshViewportRestoreRequest]) { for (key, request) in requests { self.cancelManualRefreshViewportRestoreRequest(request, for: key) } } private func viewportRestoreSwitcherSelection(for menu: NSMenu) -> ProviderSwitcherSelection? { guard self.shouldMergeIcons, menu === self.mergedMenu else { return nil } if self.isMergedOverviewSelected(in: menu) { return .overview } return .provider(self.resolvedMenuProvider() ?? .codex) } private func isCurrentManualRefreshViewportRestoreContext( _ request: ManualRefreshViewportRestoreRequest, for key: ObjectIdentifier) -> Bool { self.menuSession.isCurrentViewportRestore(request.generation, for: key) && self.menuSession.isCurrentMenuInteraction(request.menuInteractionGeneration, for: key) } private func hasMenuItemHighlightedForViewportRestore(in menu: NSMenu) -> Bool { let key = ObjectIdentifier(menu) guard let item = self.highlightedMenuItems[key], item.menu === menu else { return false } return item.isEnabled } func advanceMenuInteraction(for menu: NSMenu?) { guard let menu else { return } let key = ObjectIdentifier(menu) guard self.openMenus[key] === menu, let generation = self.menuSession.advanceMenuInteraction(for: key) else { return } (menu as? StatusItemMenu)?.menuInteractionGeneration = generation } func restoreMenuViewportToTop(_ menu: NSMenu) { #if DEBUG if let observer = self._test_menuViewportRestoreObserver { observer(menu) return } #endif guard let scrollView = Self.attachedMenuScrollView(in: menu), let documentView = scrollView.documentView else { return } let clipView = scrollView.contentView guard let target = Self.menuViewportTopOffset( documentIsFlipped: documentView.isFlipped, documentHeight: documentView.frame.height, clipHeight: clipView.bounds.height, currentOffset: clipView.documentVisibleRect.origin.y) else { return } self.performMenuMutationWithoutAnimation { clipView.scroll(to: NSPoint(x: clipView.documentVisibleRect.origin.x, y: target)) scrollView.reflectScrolledClipView(clipView) } } /// The view-based menu (`NSMenuScrollView` → `NSClipView` → table representation) /// recycles row views once they scroll offscreen, so the shared scroll view must be /// resolved through whichever item view is currently attached to the menu window. static func attachedMenuScrollView(in menu: NSMenu) -> NSScrollView? { for item in menu.items { if let scrollView = item.view?.enclosingScrollView { return scrollView } } return nil } static func menuViewportGeometry(in scrollView: NSScrollView) -> MenuViewportGeometry? { guard let documentView = scrollView.documentView else { return nil } let clipView = scrollView.contentView return MenuViewportGeometry( documentID: ObjectIdentifier(documentView), clipID: ObjectIdentifier(clipView), documentSize: documentView.frame.size, documentIsFlipped: documentView.isFlipped, clipSize: clipView.bounds.size, clipOrigin: clipView.bounds.origin) } /// Bounds notifications can arrive before AppKit exposes updated row geometry. Compare only /// coalesced, settled samples: any geometry change is layout; stable geometry exposes scrolling. static func menuViewportGeometryTransition( from previous: MenuViewportGeometry, to current: MenuViewportGeometry, movementTolerance: CGFloat = 1) -> MenuViewportGeometryTransition { // AppKit can publish an origin reset before exposing a row-size change. A mixed batch is // therefore irreducibly ambiguous: treat it as layout, then catch repeating edge-scroll // ticks against the new stable geometry on the next batch. guard previous.documentID == current.documentID, previous.clipID == current.clipID, previous.documentSize == current.documentSize, previous.documentIsFlipped == current.documentIsFlipped, previous.clipSize == current.clipSize else { return .layout } let moved = abs(current.clipOrigin.x - previous.clipOrigin.x) > movementTolerance || abs(current.clipOrigin.y - previous.clipOrigin.y) > movementTolerance return moved ? .movement : .unchanged } static func menuViewportGeometryIsAtTop( _ geometry: MenuViewportGeometry, tolerance: CGFloat = 1) -> Bool { let maximumOffset = max(0, geometry.documentSize.height - geometry.clipSize.height) let topOffset = geometry.documentIsFlipped ? 0 : maximumOffset return abs(geometry.clipOrigin.y - topOffset) <= tolerance } /// Returns the offset that shows the top of the menu content, or nil when the menu is /// not scrollable or the viewport is already there. static func menuViewportTopOffset( documentIsFlipped: Bool, documentHeight: CGFloat, clipHeight: CGFloat, currentOffset: CGFloat) -> CGFloat? { guard clipHeight > 0, documentHeight - clipHeight > 0.5 else { return nil } let top: CGFloat = documentIsFlipped ? 0 : documentHeight - clipHeight guard abs(currentOffset - top) > 0.5 else { return nil } return top } }