diff --git a/CHANGELOG.md b/CHANGELOG.md index c7636ecd9..7ec2c03e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). - Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial! - Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl! +- Menu bar: gate the provider-switcher shortcut monitor's event-queue peek behind session event counters so hover-driven menu tracking no longer calls `NSApp.nextEvent` on every run-loop pass (#1397). Thanks @bcssewl! - Development: disable Keychain access for unbundled executables to avoid repeated password prompts while preserving packaged app behavior (#1271). Thanks @Yuxin-Qiao! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index b8eb310ce..b9c6df97f 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -6,13 +6,83 @@ struct PendingProviderSwitcherRebuild { 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 + } + } +} + @MainActor final class ProviderSwitcherShortcutEventMonitor { private let callback: @MainActor (NSEvent) -> Bool private let observer: CFRunLoopObserver private var isActive = false - init(events: NSEvent.EventTypeMask, callback: @escaping @MainActor (NSEvent) -> Bool) { + init( + events: NSEvent.EventTypeMask, + peekGate: ProviderSwitcherEventPeekGate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp]), + callback: @escaping @MainActor (NSEvent) -> Bool) + { self.callback = callback self.observer = CFRunLoopObserverCreateWithHandler( @@ -20,21 +90,32 @@ final class ProviderSwitcherShortcutEventMonitor { CFRunLoopActivity.beforeSources.rawValue, true, 0) - { [events, callback] _, _ in + { [events, peekGate, callback] _, _ in MainActor.assumeIsolated { + guard peekGate.shouldPeek() else { return } + var foundEvent = false + var blockedByUnhandledEvent = false while let event = NSApp.nextEvent( matching: events, until: .distantPast, inMode: .eventTracking, dequeue: false) { - guard callback(event) else { break } + foundEvent = true + peekGate.observe(event) + guard callback(event) else { + blockedByUnhandledEvent = true + break + } _ = NSApp.nextEvent( matching: events, until: .distantPast, inMode: .eventTracking, dequeue: true) } + if !blockedByUnhandledEvent { + peekGate.observeQueueEmpty(afterFindingEvent: foundEvent) + } } } } @@ -75,7 +156,7 @@ extension StatusItemController { self.removeProviderSwitcherShortcutMonitor() let monitor = ProviderSwitcherShortcutEventMonitor( - events: [.keyDown, .leftMouseDown, .leftMouseUp]) + events: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp]) { [weak self, weak menu] event in guard let self, let menu, diff --git a/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift b/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift new file mode 100644 index 000000000..27cc01113 --- /dev/null +++ b/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift @@ -0,0 +1,149 @@ +import AppKit +import CoreGraphics +import Testing +@testable import CodexBar + +@MainActor +struct ProviderSwitcherEventPeekGateTests { + @Test + func `first check always peeks`() { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + } + + @Test + func `unchanged counters skip the peek`() { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown, .leftMouseDown], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + #expect(!gate.shouldPeek()) + } + + @Test + func `any advanced counter re-enables the peek`() { + var keyDownCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown, .leftMouseDown], + counterProvider: { type in type == .keyDown ? keyDownCount : 3 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + keyDownCount += 1 + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `counter change keeps one follow up peek for AppKit queue delivery`() { + var keyDownCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown], + counterProvider: { _ in keyDownCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + keyDownCount += 1 + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `queued unhandled event burst keeps peeking until the queue is empty`() throws { + var eventCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyUp], + counterProvider: { _ in eventCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + eventCount += 3 + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `handled event keeps peeking for delayed sibling from same counter snapshot`() throws { + var eventCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyUp], + counterProvider: { _ in eventCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + eventCount += 2 + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + gate.observeQueueEmpty(afterFindingEvent: true) + + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + gate.observeQueueEmpty(afterFindingEvent: true) + + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `held key keeps peeking for uncounted autorepeat events`() throws { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown, .keyUp], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + try gate.observe(Self.keyEvent(type: .keyDown, keyCode: 124)) + #expect(gate.shouldPeek()) + #expect(gate.shouldPeek()) + + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + private static func keyEvent(type: NSEvent.EventType, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: type, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: keyCode)) + } +}