Skip to content

Commit 13feb0f

Browse files
committed
fix: keep overview scroll on provider cards
1 parent 59d1481 commit 13feb0f

3 files changed

Lines changed: 73 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

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

89
### Fixed
910
- 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!

Sources/CodexBar/StatusItemController+OverviewScroll.swift

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ extension StatusItemController {
1414
private static let maxScrollStepsPerEvent = 3
1515

1616
/// Scrolling the wheel while the overview tab is open moves the row highlight up/down.
17-
/// Steps are delivered as synthetic arrow-key events so AppKit's native menu highlight,
18-
/// submenu, and return-key activation behavior stay intact.
17+
/// Steps are delivered as mouse-move events over the custom card views so AppKit's
18+
/// native menu highlight and submenu behavior stay intact.
1919
@discardableResult
2020
func handleOverviewScrollWheel(_ event: NSEvent, menu: NSMenu) -> Bool {
2121
guard self.menuHasOverviewRows(menu) else {
@@ -48,7 +48,7 @@ extension StatusItemController {
4848
while abs(self.overviewScrollAccumulatedDelta) >= threshold, steps < Self.maxScrollStepsPerEvent {
4949
let movingUp = self.overviewScrollAccumulatedDelta > 0
5050
self.overviewScrollAccumulatedDelta += movingUp ? -threshold : threshold
51-
self.postOverviewScrollNavigation(movingUp ? .up : .down)
51+
self.postOverviewScrollNavigation(movingUp ? .up : .down, menu: menu)
5252
steps += 1
5353
}
5454
// Discard the remainder once the cap is hit, otherwise the leftover delta from a
@@ -69,24 +69,57 @@ extension StatusItemController {
6969
self.overviewScrollAccumulatedDelta = 0
7070
}
7171

72-
private func postOverviewScrollNavigation(_ step: OverviewScrollStep) {
72+
private func postOverviewScrollNavigation(_ step: OverviewScrollStep, menu: NSMenu) {
7373
if let handler = self.overviewScrollNavigationHandlerForTesting {
7474
handler(step)
7575
return
7676
}
77-
let keyCode: UInt16 = step == .down ? 125 : 126
78-
guard let keyEvent = NSEvent.keyEvent(
79-
with: .keyDown,
80-
location: .zero,
77+
guard let target = self.overviewScrollTargetItem(in: menu, step: step) else { return }
78+
let menuID = ObjectIdentifier(menu)
79+
guard self.highlightedMenuItems[menuID] !== target else { return }
80+
81+
// Advance local state immediately so a capped multi-step flick can target successive rows
82+
// before AppKit drains the synthetic mouse-move events.
83+
self.menu(menu, willHighlight: target)
84+
85+
guard let view = target.view,
86+
let window = view.window
87+
else { return }
88+
let location = view.convert(
89+
NSPoint(x: view.bounds.midX, y: view.bounds.midY),
90+
to: nil)
91+
guard let event = NSEvent.mouseEvent(
92+
with: .mouseMoved,
93+
location: location,
8194
modifierFlags: [],
8295
timestamp: ProcessInfo.processInfo.systemUptime,
83-
windowNumber: 0,
96+
windowNumber: window.windowNumber,
8497
context: nil,
85-
characters: "",
86-
charactersIgnoringModifiers: "",
87-
isARepeat: false,
88-
keyCode: keyCode)
98+
eventNumber: 0,
99+
clickCount: 0,
100+
pressure: 0)
89101
else { return }
90-
NSApp.postEvent(keyEvent, atStart: true)
102+
NSApp.postEvent(event, atStart: false)
103+
}
104+
105+
func overviewScrollTargetItem(in menu: NSMenu, step: OverviewScrollStep) -> NSMenuItem? {
106+
let rows = menu.items.filter { item in
107+
(item.representedObject as? String)?.hasPrefix(Self.overviewRowIdentifierPrefix) == true
108+
}
109+
guard !rows.isEmpty else { return nil }
110+
111+
guard let current = self.highlightedMenuItems[ObjectIdentifier(menu)],
112+
let currentIndex = rows.firstIndex(where: { $0 === current })
113+
else {
114+
return step == .down ? rows.first : rows.last
115+
}
116+
117+
let targetIndex: Int = switch step {
118+
case .up:
119+
max(0, currentIndex - 1)
120+
case .down:
121+
min(rows.count - 1, currentIndex + 1)
122+
}
123+
return rows[targetIndex]
91124
}
92125
}

Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,31 @@ struct StatusMenuOverviewScrollTests {
7070
#expect(steps == [.down])
7171
}
7272

73+
@Test
74+
func `navigation targets only overview rows`() {
75+
let controller = self.makeController(suiteName: "OverviewScroll-Targets")
76+
defer { controller.releaseStatusItemsForTesting() }
77+
let menu = self.makeOverviewMenu()
78+
let refresh = NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "")
79+
refresh.isEnabled = true
80+
menu.addItem(refresh)
81+
let rows = Array(menu.items.prefix(2))
82+
83+
#expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[0])
84+
#expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[1])
85+
86+
controller.highlightedMenuItems[ObjectIdentifier(menu)] = rows[0]
87+
#expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[1])
88+
#expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[0])
89+
90+
controller.highlightedMenuItems[ObjectIdentifier(menu)] = rows[1]
91+
#expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[1])
92+
#expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[0])
93+
94+
controller.highlightedMenuItems[ObjectIdentifier(menu)] = refresh
95+
#expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[0])
96+
}
97+
7398
@Test
7499
func `small precise deltas accumulate before stepping`() throws {
75100
let controller = self.makeController(suiteName: "OverviewScroll-Accumulate")

0 commit comments

Comments
 (0)