Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/ui/views/eq/EQBandSliderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
)
}
}
}

Expand Down
48 changes: 48 additions & 0 deletions src/ui/views/eq/GainScrollAdjustment.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
44 changes: 44 additions & 0 deletions src/ui/views/shared/VerticalScrollWheelModifier.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
110 changes: 110 additions & 0 deletions tests/ui/GainScrollAdjustmentTests.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}