730 lines · 30.0 KB · Swift
RawDownload
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.