diff --git a/src/ui/views/eq/EQBandSliderView.swift b/src/ui/views/eq/EQBandSliderView.swift index 33388e79..10a7f124 100644 --- a/src/ui/views/eq/EQBandSliderView.swift +++ b/src/ui/views/eq/EQBandSliderView.swift @@ -15,6 +15,7 @@ struct EQBandSliderView: View { @State private var isShowingDetail = false @State private var dragStartGain: Float? = nil + @State private var gainScrollAdjustment = GainScrollAdjustment() var body: some View { VStack(spacing: 8) { @@ -145,6 +146,13 @@ struct EQBandSliderView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .onVerticalScrollWheel { event in + gain = gainScrollAdjustment.adjustedGain( + from: gain, + scrollingDeltaY: event.scrollingDeltaY, + hasPreciseScrollingDeltas: event.hasPreciseScrollingDeltas + ) + } } } diff --git a/src/ui/views/eq/GainScrollAdjustment.swift b/src/ui/views/eq/GainScrollAdjustment.swift new file mode 100644 index 00000000..f7e705de --- /dev/null +++ b/src/ui/views/eq/GainScrollAdjustment.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Converts scroll-wheel and trackpad movement into EQ gain steps. +struct GainScrollAdjustment { + static let gainStep: Float = 0.1 + static let trackpadPointsPerStep: CGFloat = 4 + static let mouseWheelDeltaPerStep: CGFloat = 1 + + private var trackpadRemainder: CGFloat = 0 + + mutating func adjustedGain( + from gain: Float, + scrollingDeltaY: CGFloat, + hasPreciseScrollingDeltas: Bool + ) -> Float { + let stepCount = scrollStepCount( + scrollingDeltaY: scrollingDeltaY, + hasPreciseScrollingDeltas: hasPreciseScrollingDeltas + ) + guard stepCount != 0 else { return gain } + + let currentStep = (gain / Self.gainStep).rounded() + let adjusted = (currentStep + Float(stepCount)) * Self.gainStep + return AudioConstants.clampGain(adjusted) + } + + private mutating func scrollStepCount( + scrollingDeltaY: CGFloat, + hasPreciseScrollingDeltas: Bool + ) -> Int { + guard scrollingDeltaY != 0 else { return 0 } + + guard hasPreciseScrollingDeltas else { + trackpadRemainder = 0 + let direction = scrollingDeltaY > 0 ? 1 : -1 + let acceleratedStepCount = max( + 1, + Int(abs(scrollingDeltaY) / Self.mouseWheelDeltaPerStep) + ) + return direction * acceleratedStepCount + } + + trackpadRemainder += scrollingDeltaY + let stepCount = Int(trackpadRemainder / Self.trackpadPointsPerStep) + trackpadRemainder -= CGFloat(stepCount) * Self.trackpadPointsPerStep + return stepCount + } +} diff --git a/src/ui/views/shared/VerticalScrollWheelModifier.swift b/src/ui/views/shared/VerticalScrollWheelModifier.swift new file mode 100644 index 00000000..61516cf2 --- /dev/null +++ b/src/ui/views/shared/VerticalScrollWheelModifier.swift @@ -0,0 +1,44 @@ +import AppKit +import SwiftUI + +extension View { + /// Handles vertical scrolling while the pointer is over this view. + func onVerticalScrollWheel(_ action: @escaping (NSEvent) -> Void) -> some View { + modifier(VerticalScrollWheelModifier(action: action)) + } +} + +private struct VerticalScrollWheelModifier: ViewModifier { + let action: (NSEvent) -> Void + + @State private var isPointerInside = false + @State private var eventMonitor: Any? + + func body(content: Content) -> some View { + content + .contentShape(Rectangle()) + .onHover { isPointerInside = $0 } + .onAppear(perform: installEventMonitor) + .onDisappear(perform: removeEventMonitor) + } + + private func installEventMonitor() { + guard eventMonitor == nil else { return } + + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in + let isVertical = abs(event.scrollingDeltaY) >= abs(event.scrollingDeltaX) + guard isPointerInside, isVertical, event.scrollingDeltaY != 0 else { + return event + } + + action(event) + return nil + } + } + + private func removeEventMonitor() { + guard let eventMonitor else { return } + NSEvent.removeMonitor(eventMonitor) + self.eventMonitor = nil + } +} diff --git a/tests/ui/GainScrollAdjustmentTests.swift b/tests/ui/GainScrollAdjustmentTests.swift new file mode 100644 index 00000000..7df3e0c6 --- /dev/null +++ b/tests/ui/GainScrollAdjustmentTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import Equaliser + +final class GainScrollAdjustmentTests: XCTestCase { + func testWheelScrollAdjustsByThreeTenthsOfADecibel() { + var adjustment = GainScrollAdjustment() + + XCTAssertEqual( + adjustment.adjustedGain( + from: 0, + scrollingDeltaY: 3, + hasPreciseScrollingDeltas: false + ), + 0.3, + accuracy: 0.0001 + ) + XCTAssertEqual( + adjustment.adjustedGain( + from: 0, + scrollingDeltaY: -3, + hasPreciseScrollingDeltas: false + ), + -0.3, + accuracy: 0.0001 + ) + } + + func testFineTrackpadMovementAccumulatesToOneTenthOfADecibel() { + var adjustment = GainScrollAdjustment() + + XCTAssertEqual( + adjustment.adjustedGain( + from: 0, + scrollingDeltaY: 1.5, + hasPreciseScrollingDeltas: true + ), + 0, + accuracy: 0.0001 + ) + XCTAssertEqual( + adjustment.adjustedGain( + from: 0, + scrollingDeltaY: 2.5, + hasPreciseScrollingDeltas: true + ), + 0.1, + accuracy: 0.0001 + ) + } + + func testAcceleratedTrackpadMovementAdjustsMultipleSteps() { + var adjustment = GainScrollAdjustment() + + XCTAssertEqual( + adjustment.adjustedGain( + from: -3, + scrollingDeltaY: 12, + hasPreciseScrollingDeltas: true + ), + -2.7, + accuracy: 0.0001 + ) + } + + func testAcceleratedMouseWheelMovementAdjustsMultipleSteps() { + var adjustment = GainScrollAdjustment() + + XCTAssertEqual( + adjustment.adjustedGain( + from: -3, + scrollingDeltaY: 9, + hasPreciseScrollingDeltas: false + ), + -2.1, + accuracy: 0.0001 + ) + XCTAssertEqual( + adjustment.adjustedGain( + from: -3, + scrollingDeltaY: -9, + hasPreciseScrollingDeltas: false + ), + -3.9, + accuracy: 0.0001 + ) + } + + func testScrollAdjustmentClampsToGainRange() { + var adjustment = GainScrollAdjustment() + + XCTAssertEqual( + adjustment.adjustedGain( + from: AudioConstants.maxGain, + scrollingDeltaY: 10, + hasPreciseScrollingDeltas: true + ), + AudioConstants.maxGain, + accuracy: 0.0001 + ) + XCTAssertEqual( + adjustment.adjustedGain( + from: AudioConstants.minGain, + scrollingDeltaY: -10, + hasPreciseScrollingDeltas: true + ), + AudioConstants.minGain, + accuracy: 0.0001 + ) + } +}