import AppKitimport CodexBarCore struct PendingProviderSwitcherRebuild { let menu: NSMenu let provider: UsageProvider?} /// Skips the event-queue peek on run-loop passes where no event of the monitored kinds/// can possibly be pending. The menu-tracking run loop spins on every mouse move, and the/// session-wide event counters for keys and clicks are far cheaper to read than/// `NSApp.nextEvent` is to call, so gating on them removes the per-pass peek cost from/// hover-heavy menu interaction (mouse moves never advance these counters).@MainActorfinal class ProviderSwitcherEventPeekGate { private let eventTypes: [CGEventType] private let counterProvider: (CGEventType) -> UInt32 private var lastCounters: [UInt32]? private var heldKeyCodes: Set<UInt16> = [] private var emptyPeekBudget = 0 init( eventTypes: [CGEventType], counterProvider: @escaping (CGEventType) -> UInt32 = { type in CGEventSource.counterForEventType(.combinedSessionState, eventType: type) }) { self.eventTypes = eventTypes self.counterProvider = counterProvider } /// True when an event of a monitored kind may have been posted since the last check. func shouldPeek() -> Bool { let counters = self.eventTypes.map(self.counterProvider) let countersChanged = self.lastCounters.map { counters != $0 } ?? true self.lastCounters = counters if countersChanged { // The observer runs before run-loop sources. WindowServer can advance a counter // one pass before AppKit queues the NSEvent, so require two empty peeks before // considering the queue caught up. self.emptyPeekBudget = max(self.emptyPeekBudget, 2) } // CoreGraphics does not count key autorepeat events. Keep peeking while a key is // held so repeated provider-navigation events are still handled. if !self.heldKeyCodes.isEmpty { return true } return self.emptyPeekBudget > 0 } func observe(_ event: NSEvent) { // An unhandled event stays queued until AppKit processes it after this observer. // Keep peeking until a later pass proves the matching queue is empty. self.emptyPeekBudget = max(self.emptyPeekBudget, 1) switch event.type { case .keyDown: self.heldKeyCodes.insert(event.keyCode) case .keyUp: self.heldKeyCodes.remove(event.keyCode) default: break } } func observeQueueEmpty(afterFindingEvent: Bool) { if afterFindingEvent { // A counter snapshot can represent multiple events that AppKit delivers across // run-loop passes. Keep one empty proof pending after draining available events. self.emptyPeekBudget = max(self.emptyPeekBudget - 1, 1) } else if self.emptyPeekBudget > 0 { self.emptyPeekBudget -= 1 } }} /// Handles provider-switcher keyboard shortcuts and overview scrolling while the merged/// status menu is open. `NSMenu` tracking pulls events itself, so local event monitors,/// Carbon dispatcher handlers, registered hot keys (tracking pushes a hotkey-disable mode),/// and `menuHasKeyEquivalent` never see these events — peeking the queue from a run-loop/// observer is the only delivery path.////// The peek itself must not disturb the tracking session: `NSApp.nextEvent` re-enters the/// event loop in the mode it is given, and re-entering `.eventTracking` dispatches the menu/// session's own timers and sources mid-observer. When that landed during menu setup or amid/// rapid claimed key repeats, it killed the session and left a zombie menu on screen that no/// longer dequeued events: clicks sat in the queue for tens of seconds while the cursor/// beach-balled. Three guards prevent that: peeks run in a private run-loop mode with no/// sources or timers registered (the queue is mode-agnostic, so matching still works), the/// peek only starts once the tracking loop is confirmed pumping, and mouse clicks are not/// monitored at all (`ProviderSwitcherView` handles those via its own `mouseDown`/`mouseUp`/// overrides), so the monitor never dequeues a click meant for AppKit.@MainActorfinal class ProviderSwitcherShortcutEventMonitor { private let callback: @MainActor (NSEvent) -> Bool private let observer: CFRunLoopObserver private let trackingState = ProviderSwitcherMenuTrackingState() private var isActive = false /// A run-loop mode nothing else registers sources or timers in, so running the loop in /// this mode while polling the event queue cannot dispatch menu-session work re-entrantly. private static let peekMode = RunLoop.Mode("com.steipete.codexbar.switcher-peek") init( events: NSEvent.EventTypeMask, peekGate: ProviderSwitcherEventPeekGate = ProviderSwitcherEventPeekGate( eventTypes: [.keyDown, .keyUp, .scrollWheel]), callback: @escaping @MainActor (NSEvent) -> Bool) { self.callback = callback let trackingState = self.trackingState self.observer = CFRunLoopObserverCreateWithHandler( nil, CFRunLoopActivity.beforeSources.rawValue, true, 0) { [events, peekGate, callback, trackingState] _, _ in MainActor.assumeIsolated { guard trackingState.isTrackingActive else { return } guard peekGate.shouldPeek() else { return } var foundEvent = false var blockedByUnhandledEvent = false while let event = NSApp.nextEvent( matching: events, until: .distantPast, inMode: Self.peekMode, dequeue: false) { foundEvent = true peekGate.observe(event) guard callback(event) else { blockedByUnhandledEvent = true break } _ = NSApp.nextEvent( matching: events, until: .distantPast, inMode: Self.peekMode, dequeue: true) } if !blockedByUnhandledEvent { peekGate.observeQueueEmpty(afterFindingEvent: foundEvent) } } } } deinit { MainActor.assumeIsolated { self.stop() } } func start() { guard !self.isActive else { return } CFRunLoopAddObserver( RunLoop.main.getCFRunLoop(), self.observer, CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) self.isActive = true // The menus this monitors are shown via `popUpMenuPositioningItem`, which posts no // NSMenu tracking notifications. Arm the gate from a block queued in the tracking // run-loop mode instead: it can only execute once the menu's tracking session is alive // and pumping the run loop, which keeps peeks away from menu setup. let trackingState = self.trackingState RunLoop.main.perform(inModes: [.eventTracking]) { MainActor.assumeIsolated { trackingState.isTrackingActive = true } } CFRunLoopWakeUp(CFRunLoopGetMain()) } func stop() { self.trackingState.isTrackingActive = false guard self.isActive else { return } CFRunLoopRemoveObserver( RunLoop.main.getCFRunLoop(), self.observer, CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) self.isActive = false }} /// Tracks whether an `NSMenu` tracking session is currently alive, so the shortcut monitor/// only touches the event queue while AppKit is actually pumping it.@MainActorprivate final class ProviderSwitcherMenuTrackingState { var isTrackingActive = false} @MainActorprivate final class ProviderSwitcherTrackingRunLoopOperation { private var operation: (@MainActor () -> Void)? init(operation: @escaping @MainActor () -> Void) { self.operation = operation } func run() { guard let operation = self.operation else { return } self.operation = nil operation() }} @MainActorenum ProviderSwitcherTrackingRunLoopScheduler { static func schedule(_ operation: @escaping @MainActor () -> Void) { let pending = ProviderSwitcherTrackingRunLoopOperation(operation: operation) let runLoop = CFRunLoopGetMain() // Main-actor tasks can starve while AppKit owns the modal menu loop. Queue in both modes so the // rebuild runs during tracking, with the default mode as a fallback if tracking ends first. let modes = [ RunLoop.Mode.eventTracking.rawValue, RunLoop.Mode.default.rawValue, ] for mode in modes { CFRunLoopPerformBlock(runLoop, mode as CFString) { MainActor.assumeIsolated { pending.run() } } } CFRunLoopWakeUp(runLoop) }} extension StatusItemController { func installProviderSwitcherShortcutMonitorIfNeeded(for menu: NSMenu) { guard self.isMenuRefreshEnabled else { return } let hasProviderSwitcher = self.shouldMergeIcons && menu.items.first?.view is ProviderSwitcherView let hasPersistentRefresh = menu.items.contains { self.isPersistentRefreshItem($0) } guard hasProviderSwitcher || hasPersistentRefresh else { return } self.removeProviderSwitcherShortcutMonitor() self.resetOverviewScrollAccumulation() // Every tracked menu observes wheel events so a manual scroll made after Refresh // invalidates that refresh's pending viewport restore. Unhandled wheel events remain // queued for AppKit's native menu scroller. let eventMask: NSEvent.EventTypeMask = [.keyDown, .keyUp, .scrollWheel] let monitor = ProviderSwitcherShortcutEventMonitor( events: eventMask) { [weak self, weak menu] event in guard let self, let menu, self.openMenus[ObjectIdentifier(menu)] != nil else { return false } return self.handleMenuTrackingShortcutEvent(event, menu: menu) } monitor.start() self.providerSwitcherShortcutEventMonitor = monitor self.providerSwitcherShortcutMenuID = ObjectIdentifier(menu) } func removeProviderSwitcherShortcutMonitor() { self.providerSwitcherShortcutEventMonitor?.stop() self.providerSwitcherShortcutEventMonitor = nil self.providerSwitcherShortcutMenuID = nil self.clearProviderSwitcherPointerInteraction() } @discardableResult func handleMenuTrackingShortcutEvent(_ event: NSEvent, menu: NSMenu) -> Bool { if event.type == .scrollWheel { self.advanceMenuInteraction(for: menu) } if StatusItemMenu.isPersistentRefreshShortcut(for: event), menu.items.contains(where: self.isPersistentRefreshItem) { if let menu = menu as? StatusItemMenu { menu.requestPersistentRefreshAction() } else { self.performPersistentRefreshAction(in: ObjectIdentifier(menu)) } return true } guard menu.items.first?.view is ProviderSwitcherView else { return false } return self.handleProviderSwitcherTrackingEvent(event, menu: menu) } func providerSwitcherContentStartIndex(in menu: NSMenu) -> Int { menu.items.first?.view is ProviderSwitcherView ? 2 : 0 } @discardableResult func handleProviderSwitcherShortcut(_ event: NSEvent, menu: NSMenu) -> Bool { if let index = StatusItemMenu.providerSelectionIndex(for: event) { return self.selectProviderSwitcherSegment(at: index, menu: menu) } if let direction = StatusItemMenu.providerNavigationDirection(for: event) { self.navigateProviderSwitcher(direction, menu: menu) return true } return false } @discardableResult func handleProviderSwitcherTrackingEvent(_ event: NSEvent, menu: NSMenu) -> Bool { switch event.type { case .keyDown: return self.handleProviderSwitcherShortcut(event, menu: menu) case .leftMouseDown: guard let switcher = menu.items.first?.view as? ProviderSwitcherView else { return false } self.beginProviderSwitcherPointerInteraction(in: menu) let handled = switcher.handleMenuTrackingMouseDown(event) if !handled { self.clearProviderSwitcherPointerInteraction(in: menu) } return handled case .leftMouseUp: guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else { return false } guard let switcher = menu.items.first?.view as? ProviderSwitcherView else { self.clearProviderSwitcherPointerInteraction(in: menu) return true } _ = switcher.handleMenuTrackingMouseUp(event) self.finishProviderSwitcherPointerInteraction(in: menu) return true case .scrollWheel: return self.handleOverviewScrollWheel(event, menu: menu) default: return false } } func requestProviderSwitcherMenuRebuild(_ menu: NSMenu, provider: UsageProvider?) { guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else { self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) return } self.pendingProviderSwitcherPointerRebuild = PendingProviderSwitcherRebuild( menu: menu, provider: provider) } private func beginProviderSwitcherPointerInteraction(in menu: NSMenu) { let menuID = ObjectIdentifier(menu) if self.providerSwitcherPointerInteractionMenuID != menuID { self.pendingProviderSwitcherPointerRebuild = nil } self.providerSwitcherPointerInteractionMenuID = menuID } private func finishProviderSwitcherPointerInteraction(in menu: NSMenu) { let menuID = ObjectIdentifier(menu) guard self.providerSwitcherPointerInteractionMenuID == menuID else { return } self.providerSwitcherPointerInteractionMenuID = nil guard let pending = self.pendingProviderSwitcherPointerRebuild, pending.menu === menu else { self.pendingProviderSwitcherPointerRebuild = nil return } self.pendingProviderSwitcherPointerRebuild = nil self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: pending.provider) } private func clearProviderSwitcherPointerInteraction(in menu: NSMenu? = nil) { if let menu, self.providerSwitcherPointerInteractionMenuID != ObjectIdentifier(menu) { return } self.providerSwitcherPointerInteractionMenuID = nil self.pendingProviderSwitcherPointerRebuild = nil } @discardableResult private func selectProviderSwitcherSegment(at index: Int, menu: NSMenu) -> Bool { guard let switcherView = menu.items.first?.view as? ProviderSwitcherView, switcherView.handleKeyboardSelection(at: index) else { return false } return true }}
import AppKit
import CodexBarCore
struct PendingProviderSwitcherRebuild {
let menu: NSMenu
let provider: UsageProvider?
}
/// Skips the event-queue peek on run-loop passes where no event of the monitored kinds
/// can possibly be pending. The menu-tracking run loop spins on every mouse move, and the
/// session-wide event counters for keys and clicks are far cheaper to read than
/// `NSApp.nextEvent` is to call, so gating on them removes the per-pass peek cost from
/// hover-heavy menu interaction (mouse moves never advance these counters).
@MainActor
final class ProviderSwitcherEventPeekGate {
private let eventTypes: [CGEventType]
private let counterProvider: (CGEventType) -> UInt32
private var lastCounters: [UInt32]?
private var heldKeyCodes: Set<UInt16> = []
private var emptyPeekBudget = 0
init(
eventTypes: [CGEventType],
counterProvider: @escaping (CGEventType) -> UInt32 = { type in
CGEventSource.counterForEventType(.combinedSessionState, eventType: type)
})
{
self.eventTypes = eventTypes
self.counterProvider = counterProvider
}
/// True when an event of a monitored kind may have been posted since the last check.
func shouldPeek() -> Bool {
let counters = self.eventTypes.map(self.counterProvider)
let countersChanged = self.lastCounters.map { counters != $0 } ?? true
self.lastCounters = counters
if countersChanged {
// The observer runs before run-loop sources. WindowServer can advance a counter
// one pass before AppKit queues the NSEvent, so require two empty peeks before
// considering the queue caught up.
self.emptyPeekBudget = max(self.emptyPeekBudget, 2)
}
// CoreGraphics does not count key autorepeat events. Keep peeking while a key is
// held so repeated provider-navigation events are still handled.
if !self.heldKeyCodes.isEmpty {
return true
}
return self.emptyPeekBudget > 0
}
func observe(_ event: NSEvent) {
// An unhandled event stays queued until AppKit processes it after this observer.
// Keep peeking until a later pass proves the matching queue is empty.
self.emptyPeekBudget = max(self.emptyPeekBudget, 1)
switch event.type {
case .keyDown:
self.heldKeyCodes.insert(event.keyCode)
case .keyUp:
self.heldKeyCodes.remove(event.keyCode)
default:
break
}
}
func observeQueueEmpty(afterFindingEvent: Bool) {
if afterFindingEvent {
// A counter snapshot can represent multiple events that AppKit delivers across
// run-loop passes. Keep one empty proof pending after draining available events.
self.emptyPeekBudget = max(self.emptyPeekBudget - 1, 1)
} else if self.emptyPeekBudget > 0 {
self.emptyPeekBudget -= 1
}
}
}
/// Handles provider-switcher keyboard shortcuts and overview scrolling while the merged
/// status menu is open. `NSMenu` tracking pulls events itself, so local event monitors,
/// Carbon dispatcher handlers, registered hot keys (tracking pushes a hotkey-disable mode),
/// and `menuHasKeyEquivalent` never see these events — peeking the queue from a run-loop
/// observer is the only delivery path.
///
/// The peek itself must not disturb the tracking session: `NSApp.nextEvent` re-enters the
/// event loop in the mode it is given, and re-entering `.eventTracking` dispatches the menu
/// session's own timers and sources mid-observer. When that landed during menu setup or amid
/// rapid claimed key repeats, it killed the session and left a zombie menu on screen that no
/// longer dequeued events: clicks sat in the queue for tens of seconds while the cursor
/// beach-balled. Three guards prevent that: peeks run in a private run-loop mode with no
/// sources or timers registered (the queue is mode-agnostic, so matching still works), the
/// peek only starts once the tracking loop is confirmed pumping, and mouse clicks are not
/// monitored at all (`ProviderSwitcherView` handles those via its own `mouseDown`/`mouseUp`
/// overrides), so the monitor never dequeues a click meant for AppKit.
@MainActor
final class ProviderSwitcherShortcutEventMonitor {
private let callback: @MainActor (NSEvent) -> Bool
private let observer: CFRunLoopObserver
private let trackingState = ProviderSwitcherMenuTrackingState()
private var isActive = false
/// A run-loop mode nothing else registers sources or timers in, so running the loop in
/// this mode while polling the event queue cannot dispatch menu-session work re-entrantly.
private static let peekMode = RunLoop.Mode("com.steipete.codexbar.switcher-peek")
init(
events: NSEvent.EventTypeMask,
peekGate: ProviderSwitcherEventPeekGate = ProviderSwitcherEventPeekGate(
eventTypes: [.keyDown, .keyUp, .scrollWheel]),
callback: @escaping @MainActor (NSEvent) -> Bool)
{
self.callback = callback
let trackingState = self.trackingState
self.observer = CFRunLoopObserverCreateWithHandler(
nil,
CFRunLoopActivity.beforeSources.rawValue,
true,
0)
{ [events, peekGate, callback, trackingState] _, _ in
MainActor.assumeIsolated {
guard trackingState.isTrackingActive else { return }
guard peekGate.shouldPeek() else { return }
var foundEvent = false
var blockedByUnhandledEvent = false
while let event = NSApp.nextEvent(
matching: events,
until: .distantPast,
inMode: Self.peekMode,
dequeue: false)
{
foundEvent = true
peekGate.observe(event)
guard callback(event) else {
blockedByUnhandledEvent = true
break
}
_ = NSApp.nextEvent(
matching: events,
until: .distantPast,
inMode: Self.peekMode,
dequeue: true)
}
if !blockedByUnhandledEvent {
peekGate.observeQueueEmpty(afterFindingEvent: foundEvent)
}
}
}
}
deinit {
MainActor.assumeIsolated {
self.stop()
}
}
func start() {
guard !self.isActive else { return }
CFRunLoopAddObserver(
RunLoop.main.getCFRunLoop(),
self.observer,
CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
self.isActive = true
// The menus this monitors are shown via `popUpMenuPositioningItem`, which posts no
// NSMenu tracking notifications. Arm the gate from a block queued in the tracking
// run-loop mode instead: it can only execute once the menu's tracking session is alive
// and pumping the run loop, which keeps peeks away from menu setup.
let trackingState = self.trackingState
RunLoop.main.perform(inModes: [.eventTracking]) {
MainActor.assumeIsolated {
trackingState.isTrackingActive = true
}
}
CFRunLoopWakeUp(CFRunLoopGetMain())
}
func stop() {
self.trackingState.isTrackingActive = false
guard self.isActive else { return }
CFRunLoopRemoveObserver(
RunLoop.main.getCFRunLoop(),
self.observer,
CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
self.isActive = false
}
}
/// Tracks whether an `NSMenu` tracking session is currently alive, so the shortcut monitor
/// only touches the event queue while AppKit is actually pumping it.
@MainActor
private final class ProviderSwitcherMenuTrackingState {
var isTrackingActive = false
}
@MainActor
private final class ProviderSwitcherTrackingRunLoopOperation {
private var operation: (@MainActor () -> Void)?
init(operation: @escaping @MainActor () -> Void) {
self.operation = operation
}
func run() {
guard let operation = self.operation else { return }
self.operation = nil
operation()
}
}
@MainActor
enum ProviderSwitcherTrackingRunLoopScheduler {
static func schedule(_ operation: @escaping @MainActor () -> Void) {
let pending = ProviderSwitcherTrackingRunLoopOperation(operation: operation)
let runLoop = CFRunLoopGetMain()
// Main-actor tasks can starve while AppKit owns the modal menu loop. Queue in both modes so the
// rebuild runs during tracking, with the default mode as a fallback if tracking ends first.
let modes = [
RunLoop.Mode.eventTracking.rawValue,
RunLoop.Mode.default.rawValue,
]
for mode in modes {
CFRunLoopPerformBlock(runLoop, mode as CFString) {
MainActor.assumeIsolated {
pending.run()
}
}
}
CFRunLoopWakeUp(runLoop)
}
}
extension StatusItemController {
func installProviderSwitcherShortcutMonitorIfNeeded(for menu: NSMenu) {
guard self.isMenuRefreshEnabled else {
return
}
let hasProviderSwitcher = self.shouldMergeIcons && menu.items.first?.view is ProviderSwitcherView
let hasPersistentRefresh = menu.items.contains { self.isPersistentRefreshItem($0) }
guard hasProviderSwitcher || hasPersistentRefresh else {
return
}
self.removeProviderSwitcherShortcutMonitor()
self.resetOverviewScrollAccumulation()
// Every tracked menu observes wheel events so a manual scroll made after Refresh
// invalidates that refresh's pending viewport restore. Unhandled wheel events remain
// queued for AppKit's native menu scroller.
let eventMask: NSEvent.EventTypeMask = [.keyDown, .keyUp, .scrollWheel]
let monitor = ProviderSwitcherShortcutEventMonitor(
events: eventMask)
{ [weak self, weak menu] event in
guard let self,
let menu,
self.openMenus[ObjectIdentifier(menu)] != nil
else {
return false
}
return self.handleMenuTrackingShortcutEvent(event, menu: menu)
}
monitor.start()
self.providerSwitcherShortcutEventMonitor = monitor
self.providerSwitcherShortcutMenuID = ObjectIdentifier(menu)
}
func removeProviderSwitcherShortcutMonitor() {
self.providerSwitcherShortcutEventMonitor?.stop()
self.providerSwitcherShortcutEventMonitor = nil
self.providerSwitcherShortcutMenuID = nil
self.clearProviderSwitcherPointerInteraction()
}
@discardableResult
func handleMenuTrackingShortcutEvent(_ event: NSEvent, menu: NSMenu) -> Bool {
if event.type == .scrollWheel {
self.advanceMenuInteraction(for: menu)
}
if StatusItemMenu.isPersistentRefreshShortcut(for: event),
menu.items.contains(where: self.isPersistentRefreshItem)
{
if let menu = menu as? StatusItemMenu {
menu.requestPersistentRefreshAction()
} else {
self.performPersistentRefreshAction(in: ObjectIdentifier(menu))
}
return true
}
guard menu.items.first?.view is ProviderSwitcherView else { return false }
return self.handleProviderSwitcherTrackingEvent(event, menu: menu)
}
func providerSwitcherContentStartIndex(in menu: NSMenu) -> Int {
menu.items.first?.view is ProviderSwitcherView ? 2 : 0
}
@discardableResult
func handleProviderSwitcherShortcut(_ event: NSEvent, menu: NSMenu) -> Bool {
if let index = StatusItemMenu.providerSelectionIndex(for: event) {
return self.selectProviderSwitcherSegment(at: index, menu: menu)
}
if let direction = StatusItemMenu.providerNavigationDirection(for: event) {
self.navigateProviderSwitcher(direction, menu: menu)
return true
}
return false
}
@discardableResult
func handleProviderSwitcherTrackingEvent(_ event: NSEvent, menu: NSMenu) -> Bool {
switch event.type {
case .keyDown:
return self.handleProviderSwitcherShortcut(event, menu: menu)
case .leftMouseDown:
guard let switcher = menu.items.first?.view as? ProviderSwitcherView else { return false }
self.beginProviderSwitcherPointerInteraction(in: menu)
let handled = switcher.handleMenuTrackingMouseDown(event)
if !handled {
self.clearProviderSwitcherPointerInteraction(in: menu)
}
return handled
case .leftMouseUp:
guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else {
return false
}
guard let switcher = menu.items.first?.view as? ProviderSwitcherView else {
self.clearProviderSwitcherPointerInteraction(in: menu)
return true
}
_ = switcher.handleMenuTrackingMouseUp(event)
self.finishProviderSwitcherPointerInteraction(in: menu)
return true
case .scrollWheel:
return self.handleOverviewScrollWheel(event, menu: menu)
default:
return false
}
}
func requestProviderSwitcherMenuRebuild(_ menu: NSMenu, provider: UsageProvider?) {
guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else {
self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider)
return
}
self.pendingProviderSwitcherPointerRebuild = PendingProviderSwitcherRebuild(
menu: menu,
provider: provider)
}
private func beginProviderSwitcherPointerInteraction(in menu: NSMenu) {
let menuID = ObjectIdentifier(menu)
if self.providerSwitcherPointerInteractionMenuID != menuID {
self.pendingProviderSwitcherPointerRebuild = nil
}
self.providerSwitcherPointerInteractionMenuID = menuID
}
private func finishProviderSwitcherPointerInteraction(in menu: NSMenu) {
let menuID = ObjectIdentifier(menu)
guard self.providerSwitcherPointerInteractionMenuID == menuID else { return }
self.providerSwitcherPointerInteractionMenuID = nil
guard let pending = self.pendingProviderSwitcherPointerRebuild,
pending.menu === menu
else {
self.pendingProviderSwitcherPointerRebuild = nil
return
}
self.pendingProviderSwitcherPointerRebuild = nil
self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: pending.provider)
}
private func clearProviderSwitcherPointerInteraction(in menu: NSMenu? = nil) {
if let menu,
self.providerSwitcherPointerInteractionMenuID != ObjectIdentifier(menu)
{
return
}
self.providerSwitcherPointerInteractionMenuID = nil
self.pendingProviderSwitcherPointerRebuild = nil
}
@discardableResult
private func selectProviderSwitcherSegment(at index: Int, menu: NSMenu) -> Bool {
guard let switcherView = menu.items.first?.view as? ProviderSwitcherView,
switcherView.handleKeyboardSelection(at: index)
else {
return false
}
return true
}
}