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 = [] 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 } }