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 @@ -4,6 +4,7 @@

### Added
- Devin: add daily and weekly quota tracking from the signed-in Chrome session or a manual Bearer token (#1264, fixes #800). Thanks @coygeek!
- Menu bar: move the highlighted Overview provider with trackpad or mouse-wheel scrolling while preserving native submenu and keyboard behavior (#1436). Thanks @joshuavial!

### Fixed
- Menu bar: anchor merged provider dropdowns to the status item's trailing edge without marking preserved in-flight refresh content fresh, preventing horizontal drift while keeping deferred updates visible (#1288). Thanks @Yuxin-Qiao!
Expand Down
125 changes: 125 additions & 0 deletions Sources/CodexBar/StatusItemController+OverviewScroll.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import AppKit

enum OverviewScrollStep {
case up
case down
}

extension StatusItemController {
/// Pixel distance per highlight step for trackpads and other precise devices.
private static let preciseScrollStepThreshold: CGFloat = 24
/// Line distance per highlight step for classic scroll wheels.
private static let lineScrollStepThreshold: CGFloat = 0.9
/// A single fast flick should not race the highlight through the whole list.
private static let maxScrollStepsPerEvent = 3

/// Scrolling the wheel while the overview tab is open moves the row highlight up/down.
/// Steps are delivered as mouse-move events over the custom card views so AppKit's
/// native menu highlight and submenu behavior stay intact.
@discardableResult
func handleOverviewScrollWheel(_ event: NSEvent, menu: NSMenu) -> Bool {
guard self.menuHasOverviewRows(menu) else {
self.overviewScrollAccumulatedDelta = 0
return false
}
// Leave the wheel alone while a row submenu is open (e.g. scrollable charts);
// only the root overview list translates scrolling into highlight movement.
guard self.openMenus.count <= 1 else {
self.overviewScrollAccumulatedDelta = 0
return false
}
// Momentum-phase events after a flick would keep moving the highlight long after
// the fingers left the trackpad; swallow them without stepping.
guard event.momentumPhase.isEmpty else { return true }
let delta = event.scrollingDeltaY
guard delta != 0 else { return false }

if self.overviewScrollAccumulatedDelta != 0,
(delta > 0) != (self.overviewScrollAccumulatedDelta > 0)
{
self.overviewScrollAccumulatedDelta = 0
}
self.overviewScrollAccumulatedDelta += delta

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset accumulation when scroll units change

When a menu stays open and the user switches between a precise device and a line-based wheel, this accumulator combines values measured in different units while the threshold below is chosen from only the current event. For example, a leftover 20 pixel trackpad delta followed by one wheel notch with the same sign is compared against the 0.9 line threshold and emits the capped 3 steps instead of one, so users with an external mouse/trackpad can see the highlight jump unexpectedly. Track the last hasPreciseScrollingDeltas mode and clear the remainder when it changes, or keep separate accumulators per unit.

Useful? React with 👍 / 👎.


let threshold = event.hasPreciseScrollingDeltas
? Self.preciseScrollStepThreshold
: Self.lineScrollStepThreshold
var steps = 0
while abs(self.overviewScrollAccumulatedDelta) >= threshold, steps < Self.maxScrollStepsPerEvent {
let movingUp = self.overviewScrollAccumulatedDelta > 0
self.overviewScrollAccumulatedDelta += movingUp ? -threshold : threshold
self.postOverviewScrollNavigation(movingUp ? .up : .down, menu: menu)
steps += 1
}
Comment thread
joshuavial marked this conversation as resolved.
// Discard the remainder once the cap is hit, otherwise the leftover delta from a
// fast flick would keep emitting capped batches on the next small scroll.
if steps == Self.maxScrollStepsPerEvent {
self.overviewScrollAccumulatedDelta = 0
}
return true
}

func menuHasOverviewRows(_ menu: NSMenu) -> Bool {
menu.items.contains { item in
(item.representedObject as? String)?.hasPrefix(Self.overviewRowIdentifierPrefix) == true
}
}

func resetOverviewScrollAccumulation() {
self.overviewScrollAccumulatedDelta = 0
}

private func postOverviewScrollNavigation(_ step: OverviewScrollStep, menu: NSMenu) {
if let handler = self.overviewScrollNavigationHandlerForTesting {
handler(step)
return
}
guard let target = self.overviewScrollTargetItem(in: menu, step: step) else { return }
let menuID = ObjectIdentifier(menu)
guard self.highlightedMenuItems[menuID] !== target else { return }

// Advance local state immediately so a capped multi-step flick can target successive rows
// before AppKit drains the synthetic mouse-move events.
self.menu(menu, willHighlight: target)

guard let view = target.view,
let window = view.window
else { return }
let location = view.convert(
NSPoint(x: view.bounds.midX, y: view.bounds.midY),
to: nil)
guard let event = NSEvent.mouseEvent(
with: .mouseMoved,
location: location,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 0,
clickCount: 0,
pressure: 0)
else { return }
NSApp.postEvent(event, atStart: false)
}

func overviewScrollTargetItem(in menu: NSMenu, step: OverviewScrollStep) -> NSMenuItem? {
let rows = menu.items.filter { item in
(item.representedObject as? String)?.hasPrefix(Self.overviewRowIdentifierPrefix) == true
}
guard !rows.isEmpty else { return nil }

guard let current = self.highlightedMenuItems[ObjectIdentifier(menu)],
let currentIndex = rows.firstIndex(where: { $0 === current })
else {
return step == .down ? rows.first : rows.last
}

let targetIndex: Int = switch step {
case .up:
max(0, currentIndex - 1)
case .down:
min(rows.count - 1, currentIndex + 1)
}
return rows[targetIndex]
}
}
7 changes: 6 additions & 1 deletion Sources/CodexBar/StatusItemController+ProviderSwitcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,11 @@ extension StatusItemController {
}

self.removeProviderSwitcherShortcutMonitor()
self.resetOverviewScrollAccumulation()
let monitor = ProviderSwitcherShortcutEventMonitor(
events: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp])
events: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp, .scrollWheel],
peekGate: ProviderSwitcherEventPeekGate(
eventTypes: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp, .scrollWheel]))
{ [weak self, weak menu] event in
guard let self,
let menu,
Expand Down Expand Up @@ -220,6 +223,8 @@ extension StatusItemController {
_ = switcher.handleMenuTrackingMouseUp(event)
self.finishProviderSwitcherPointerInteraction(in: menu)
return true
case .scrollWheel:
return self.handleOverviewScrollWheel(event, menu: menu)
default:
return false
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
var providerSwitcherShortcutMenuID: ObjectIdentifier?
var providerSwitcherPointerInteractionMenuID: ObjectIdentifier?
var pendingProviderSwitcherPointerRebuild: PendingProviderSwitcherRebuild?
var overviewScrollAccumulatedDelta: CGFloat = 0
var overviewScrollNavigationHandlerForTesting: ((OverviewScrollStep) -> Void)?
var hasPreparedForAppShutdown = false
var scheduleQuitTermination: (@escaping @MainActor () -> Void) -> Void = { operation in
DispatchQueue.main.async {
Expand Down
216 changes: 216 additions & 0 deletions Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import AppKit
import CodexBarCore
import Foundation
import Testing
@testable import CodexBar

@MainActor
struct StatusMenuOverviewScrollTests {
private func makeController(suiteName: String) -> StatusItemController {
_ = NSApplication.shared
let settings = SettingsStore(
configStore: testConfigStore(suiteName: suiteName),
zaiTokenStore: NoopZaiTokenStore(),
syntheticTokenStore: NoopSyntheticTokenStore())
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
let fetcher = UsageFetcher()
let store = UsageStore(
fetcher: fetcher,
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
return StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: .system)
}

private func makeOverviewMenu() -> NSMenu {
let menu = NSMenu()
for provider in ["claude", "codex"] {
let item = NSMenuItem()
item.representedObject = "\(StatusItemController.overviewRowIdentifierPrefix)\(provider)"
item.isEnabled = true
menu.addItem(item)
}
return menu
}

private func makeScrollEvent(deltaY: Double, precise: Bool) -> NSEvent? {
guard let cgEvent = CGEvent(
scrollWheelEvent2Source: nil,
units: precise ? .pixel : .line,
wheelCount: 1,
wheel1: Int32(deltaY),
wheel2: 0,
wheel3: 0)
else { return nil }
return NSEvent(cgEvent: cgEvent)
}

@Test
func `scroll steps move highlight and respect direction`() throws {
let controller = self.makeController(suiteName: "OverviewScroll-Direction")
defer { controller.releaseStatusItemsForTesting() }
let menu = self.makeOverviewMenu()

var steps: [OverviewScrollStep] = []
controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) }

let scrollUp = try #require(self.makeScrollEvent(deltaY: 30, precise: true))
#expect(controller.handleOverviewScrollWheel(scrollUp, menu: menu))
#expect(steps == [.up])

steps = []
let scrollDown = try #require(self.makeScrollEvent(deltaY: -30, precise: true))
#expect(controller.handleOverviewScrollWheel(scrollDown, menu: menu))
#expect(steps == [.down])
}

@Test
func `navigation targets only overview rows`() {
let controller = self.makeController(suiteName: "OverviewScroll-Targets")
defer { controller.releaseStatusItemsForTesting() }
let menu = self.makeOverviewMenu()
let refresh = NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "")
refresh.isEnabled = true
menu.addItem(refresh)
let rows = Array(menu.items.prefix(2))

#expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[0])
#expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[1])

controller.highlightedMenuItems[ObjectIdentifier(menu)] = rows[0]
#expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[1])
#expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[0])

controller.highlightedMenuItems[ObjectIdentifier(menu)] = rows[1]
#expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[1])
#expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[0])

controller.highlightedMenuItems[ObjectIdentifier(menu)] = refresh
#expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[0])
}

@Test
func `small precise deltas accumulate before stepping`() throws {
let controller = self.makeController(suiteName: "OverviewScroll-Accumulate")
defer { controller.releaseStatusItemsForTesting() }
let menu = self.makeOverviewMenu()

var steps: [OverviewScrollStep] = []
controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) }

let smallScroll = try #require(self.makeScrollEvent(deltaY: 10, precise: true))
#expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu))
#expect(steps.isEmpty)
#expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu))
#expect(steps.isEmpty)
#expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu))
#expect(steps == [.up])
}

@Test
func `direction change resets accumulated distance`() throws {
let controller = self.makeController(suiteName: "OverviewScroll-Flip")
defer { controller.releaseStatusItemsForTesting() }
let menu = self.makeOverviewMenu()

var steps: [OverviewScrollStep] = []
controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) }

let upBelowThreshold = try #require(self.makeScrollEvent(deltaY: 20, precise: true))
#expect(controller.handleOverviewScrollWheel(upBelowThreshold, menu: menu))
#expect(steps.isEmpty)

let downBelowThreshold = try #require(self.makeScrollEvent(deltaY: -20, precise: true))
#expect(controller.handleOverviewScrollWheel(downBelowThreshold, menu: menu))
#expect(steps.isEmpty)
#expect(controller.handleOverviewScrollWheel(downBelowThreshold, menu: menu))
#expect(steps == [.down])
}

@Test
func `coarse wheel lines step immediately`() throws {
let controller = self.makeController(suiteName: "OverviewScroll-Wheel")
defer { controller.releaseStatusItemsForTesting() }
let menu = self.makeOverviewMenu()

var steps: [OverviewScrollStep] = []
controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) }

let wheelNotch = try #require(self.makeScrollEvent(deltaY: -1, precise: false))
#expect(controller.handleOverviewScrollWheel(wheelNotch, menu: menu))
#expect(steps == [.down])
}

@Test
func `fast flick is capped per event`() throws {
let controller = self.makeController(suiteName: "OverviewScroll-Cap")
defer { controller.releaseStatusItemsForTesting() }
let menu = self.makeOverviewMenu()

var steps: [OverviewScrollStep] = []
controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) }

let flick = try #require(self.makeScrollEvent(deltaY: 500, precise: true))
#expect(controller.handleOverviewScrollWheel(flick, menu: menu))
#expect(steps == [.up, .up, .up])
}

@Test
func `capped flick discards leftover distance`() throws {
let controller = self.makeController(suiteName: "OverviewScroll-CapRemainder")
defer { controller.releaseStatusItemsForTesting() }
let menu = self.makeOverviewMenu()

var steps: [OverviewScrollStep] = []
controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) }

let flick = try #require(self.makeScrollEvent(deltaY: 500, precise: true))
#expect(controller.handleOverviewScrollWheel(flick, menu: menu))
#expect(steps == [.up, .up, .up])

steps = []
let smallScroll = try #require(self.makeScrollEvent(deltaY: 10, precise: true))
#expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu))
#expect(steps.isEmpty)
}

@Test
func `open submenu suspends scroll navigation`() throws {
let controller = self.makeController(suiteName: "OverviewScroll-Submenu")
defer { controller.releaseStatusItemsForTesting() }
let menu = self.makeOverviewMenu()
let submenu = NSMenu()
controller.openMenus[ObjectIdentifier(menu)] = menu
controller.openMenus[ObjectIdentifier(submenu)] = submenu
defer { controller.openMenus.removeAll() }

var steps: [OverviewScrollStep] = []
controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) }

let scroll = try #require(self.makeScrollEvent(deltaY: 30, precise: true))
#expect(!controller.handleOverviewScrollWheel(scroll, menu: menu))
#expect(steps.isEmpty)
}

@Test
func `menus without overview rows ignore scrolling`() throws {
let controller = self.makeController(suiteName: "OverviewScroll-NonOverview")
defer { controller.releaseStatusItemsForTesting() }
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Refresh", action: nil, keyEquivalent: ""))

var steps: [OverviewScrollStep] = []
controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) }

let scroll = try #require(self.makeScrollEvent(deltaY: 30, precise: true))
#expect(!controller.handleOverviewScrollWheel(scroll, menu: menu))
#expect(steps.isEmpty)
#expect(!menu.items.isEmpty)
}
}
Loading