Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
89 changes: 85 additions & 4 deletions Sources/CodexBar/StatusItemController+ProviderSwitcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,116 @@ 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<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
}
}
}

@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(
nil,
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)
}
}
}
}
Expand Down Expand Up @@ -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,
Expand Down
149 changes: 149 additions & 0 deletions Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}