Files
steipete--codexbar/Sources/CodexBar/StatusItemController+OverviewScroll.swift
T
2026-07-13 12:22:33 +08:00

127 lines
5.0 KiB
Swift

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]
}
}