388 lines
16 KiB
Swift
388 lines
16 KiB
Swift
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
|
|
}
|
|
}
|