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 }} @MainActorfinal 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} @MainActorfinal 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,Lines 1–500 of 730. Load more
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,