chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user