import AppKit enum OverviewScrollStep { case up case down} extension StatusItemController { /// 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 /// Classic scroll wheels keep row-to-row overview navigation. Precise trackpad scrolling is /// left to AppKit's native menu scroller so the content follows the user's fingers instead /// of waiting for a threshold and jumping the highlighted row. @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 } guard !event.hasPreciseScrollingDeltas else { self.overviewScrollAccumulatedDelta = 0 return false } // Precise trackpad/Magic Mouse scrolling already returned above, so this only guards // non-precise devices that still report a momentum phase: swallow that flick tail so the // highlight does not keep stepping after the fingers lift. 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 let threshold = 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 } // 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] }}
import AppKit
enum OverviewScrollStep {
case up
case down
}
extension StatusItemController {
/// 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
/// Classic scroll wheels keep row-to-row overview navigation. Precise trackpad scrolling is
/// left to AppKit's native menu scroller so the content follows the user's fingers instead
/// of waiting for a threshold and jumping the highlighted row.
@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
}
guard !event.hasPreciseScrollingDeltas else {
self.overviewScrollAccumulatedDelta = 0
return false
}
// Precise trackpad/Magic Mouse scrolling already returned above, so this only guards
// non-precise devices that still report a momentum phase: swallow that flick tail so the
// highlight does not keep stepping after the fingers lift.
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
let threshold = 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
}
// 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]
}
}