731 lines
30 KiB
Swift
731 lines
30 KiB
Swift
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
|
|
}
|
|
}
|