diff --git a/Package.swift b/Package.swift index 200af6f..b3329d6 100644 --- a/Package.swift +++ b/Package.swift @@ -21,9 +21,16 @@ let package = Package( .linkedFramework("ApplicationServices") ] ), + .target( + name: "ScrollEngine", + path: "Sources/ScrollEngine", + swiftSettings: [ + .unsafeFlags(["-warnings-as-errors"]) + ] + ), .executableTarget( name: "WinMice", - dependencies: ["SwipeGesturePoster"], + dependencies: ["SwipeGesturePoster", "ScrollEngine"], path: "Sources/WinMice", swiftSettings: [ .unsafeFlags(["-warnings-as-errors"]) @@ -42,6 +49,11 @@ let package = Package( linkerSettings: [ .linkedFramework("AppKit") ] + ), + .testTarget( + name: "ScrollEngineTests", + dependencies: ["ScrollEngine"], + path: "Tests/ScrollEngineTests" ) ] ) diff --git a/README.md b/README.md index 4d05c41..e3d3a20 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Open **Settings…** from the menu bar to configure: - **Hold to Scroll** — scrolling starts on middle-click and stops when you release it. - **Hold to Start** — hold middle-click for a configurable delay (default 200 ms), then scroll until any mouse button is pressed. - **Speed** — 25–300%, for anyone who wants the pointer to travel more or less before things move. +- **Reverse vertical** — drag down to scroll up instead of down. +- **Reverse horizontal** — drag right to scroll left instead of right. - Choose indicator style (light/dark), size (28–48 px), and scroll mode in Settings. The distance from the anchor maps to speed slightly faster than linearly, so the first few diff --git a/Sources/WinMice/ScrollEngine.swift b/Sources/ScrollEngine/ScrollEngine.swift similarity index 70% rename from Sources/WinMice/ScrollEngine.swift rename to Sources/ScrollEngine/ScrollEngine.swift index 8873f00..83cc9dd 100644 --- a/Sources/WinMice/ScrollEngine.swift +++ b/Sources/ScrollEngine/ScrollEngine.swift @@ -11,14 +11,18 @@ import CoreGraphics /// below roughly 1 px per tick it rounds slow drags away to nothing at all. /// - The response is slightly faster than linear, so the first few points past the dead zone stay /// precise while the far end of the screen still scrolls quickly. -struct ScrollEngine { +public struct ScrollEngine { /// Pixels scrolled per tick one point past the dead zone, before the user's speed preference. - static let baseSpeed: CGFloat = 0.10 + public static let baseSpeed: CGFloat = 0.10 /// Pointer distance from the anchor, in points, that scrolls nothing. - var deadZone: CGFloat = 12 + public var deadZone: CGFloat = 12 /// Pixels scrolled per tick one point past the dead zone. - var speed = baseSpeed + public var speed = baseSpeed + /// Inverts the vertical wheel delta, so dragging down scrolls up. + public var reverseVertical = false + /// Inverts the horizontal wheel delta, so dragging right scrolls left. + public var reverseHorizontal = false /// Exponent on the distance past the dead zone. Above 1 this buys fine control near the anchor /// at the cost of a steeper ramp further out. var acceleration: CGFloat = 1.35 @@ -27,13 +31,15 @@ struct ScrollEngine { private var remainder = CGVector.zero - mutating func reset() { + public init() {} + + public mutating func reset() { remainder = .zero } /// - Parameter offset: Pointer position minus anchor, in AppKit coordinates (y grows upward). /// - Returns: Wheel deltas for this tick, or `nil` when there is nothing to scroll. - mutating func tick(offset: CGVector) -> (vertical: Int32, horizontal: Int32)? { + public mutating func tick(offset: CGVector) -> (vertical: Int32, horizontal: Int32)? { let distance = (offset.dx * offset.dx + offset.dy * offset.dy).squareRoot() guard distance > deadZone else { reset() @@ -42,9 +48,11 @@ struct ScrollEngine { let magnitude = min(speed * pow(distance - deadZone, acceleration), maxDeltaPerTick) // Positive wheel1 scrolls up and positive wheel2 scrolls left, so the vertical offset maps - // straight across and the horizontal one inverts. - let vertical = magnitude * offset.dy / distance + remainder.dy - let horizontal = -magnitude * offset.dx / distance + remainder.dx + // straight across and the horizontal one inverts. The reverse flags flip either axis on top + // of that, before the remainder is added, so the carryover stays in the same sign + // convention as the deltas it accumulates toward. + let vertical = (reverseVertical ? -1 : 1) * magnitude * offset.dy / distance + remainder.dy + let horizontal = -(reverseHorizontal ? -1 : 1) * magnitude * offset.dx / distance + remainder.dx let steps = CGVector(dx: horizontal.rounded(.towardZero), dy: vertical.rounded(.towardZero)) remainder = CGVector(dx: horizontal - steps.dx, dy: vertical - steps.dy) diff --git a/Sources/WinMice/AppSettings.swift b/Sources/WinMice/AppSettings.swift index 8a72c56..47bc57a 100644 --- a/Sources/WinMice/AppSettings.swift +++ b/Sources/WinMice/AppSettings.swift @@ -37,6 +37,8 @@ final class AppSettings: ObservableObject { static let holdToLockMode = Preference("holdToLockMode", default: false) static let holdToStartDelayMs = Preference("holdToStartDelayMs", default: 200) static let scrollSpeedPercent = Preference("scrollSpeedPercent", default: 100) + static let reverseScrollDirectionVertical = Preference("reverseScrollDirectionVertical", default: false) + static let reverseScrollDirectionHorizontal = Preference("reverseScrollDirectionHorizontal", default: false) static let markerSize = Preference("markerSize", default: 32) static let sideButtonsEnabled = Preference("sideButtonsEnabled", default: true) static let navigationMethod = Preference("navigationMethod", default: NavigationMethod.swipe) @@ -52,6 +54,8 @@ final class AppSettings: ObservableObject { holdToLockMode.key, holdToStartDelayMs.key, scrollSpeedPercent.key, + reverseScrollDirectionVertical.key, + reverseScrollDirectionHorizontal.key, markerSize.key, sideButtonsEnabled.key, navigationMethod.key, @@ -84,6 +88,16 @@ final class AppSettings: ObservableObject { set { write(Self.clamp(newValue, to: Self.scrollSpeedRange, step: Self.scrollSpeedStep), to: Key.scrollSpeedPercent) } } + var reverseScrollDirectionVertical: Bool { + get { defaults[Key.reverseScrollDirectionVertical] } + set { write(newValue, to: Key.reverseScrollDirectionVertical) } + } + + var reverseScrollDirectionHorizontal: Bool { + get { defaults[Key.reverseScrollDirectionHorizontal] } + set { write(newValue, to: Key.reverseScrollDirectionHorizontal) } + } + var markerSize: Int { get { Self.nearestMarkerSize(defaults[Key.markerSize]) } set { write(Self.nearestMarkerSize(newValue), to: Key.markerSize) } diff --git a/Sources/WinMice/Settings/Panes/ScrollingPane.swift b/Sources/WinMice/Settings/Panes/ScrollingPane.swift index 02b3cf8..041ca86 100644 --- a/Sources/WinMice/Settings/Panes/ScrollingPane.swift +++ b/Sources/WinMice/Settings/Panes/ScrollingPane.swift @@ -48,6 +48,18 @@ struct ScrollingPane: View { } } + Section("Direction") { + Toggle(isOn: $settings.reverseScrollDirectionVertical) { + Text("Reverse vertical") + Text("Drag down to scroll up instead of down.") + } + + Toggle(isOn: $settings.reverseScrollDirectionHorizontal) { + Text("Reverse horizontal") + Text("Drag right to scroll left instead of right.") + } + } + Section("Indicator") { LabeledContent("Appearance") { Picker("Appearance", selection: $settings.darkMode) { diff --git a/Sources/WinMice/main.swift b/Sources/WinMice/main.swift index 99b9277..bcf9856 100644 --- a/Sources/WinMice/main.swift +++ b/Sources/WinMice/main.swift @@ -1,5 +1,6 @@ @preconcurrency import AppKit @preconcurrency import ApplicationServices +import ScrollEngine @MainActor private final class WinMiceApp: NSObject, NSApplicationDelegate { @@ -268,6 +269,8 @@ private final class WinMiceApp: NSObject, NSApplicationDelegate { private func applySettings() { indicator.configure(darkMode: settings.darkMode, size: CGFloat(settings.markerSize)) scroll.speed = ScrollEngine.baseSpeed * CGFloat(settings.scrollSpeedPercent) / 100 + scroll.reverseVertical = settings.reverseScrollDirectionVertical + scroll.reverseHorizontal = settings.reverseScrollDirectionHorizontal navigation.enabled = settings.sideButtonsEnabled navigation.method = settings.navigationMethod diff --git a/Tests/ScrollEngineTests/ScrollEngineTests.swift b/Tests/ScrollEngineTests/ScrollEngineTests.swift new file mode 100644 index 0000000..17f958f --- /dev/null +++ b/Tests/ScrollEngineTests/ScrollEngineTests.swift @@ -0,0 +1,125 @@ +import CoreGraphics +import ScrollEngine +import XCTest + +/// Wheel deltas follow CoreGraphics conventions: positive `wheel1` scrolls up and positive `wheel2` +/// scrolls left, so "scrolls down" below means a negative vertical delta and "scrolls right" means a +/// negative horizontal one. +final class ScrollEngineTests: XCTestCase { + /// Far enough past the dead zone that a single tick produces whole pixels on either axis. + private let far: CGFloat = 200 + + func testDraggingDownScrollsDown() { + var engine = ScrollEngine() + + let delta = engine.tick(offset: CGVector(dx: 0, dy: -far)) + + XCTAssertLessThan(delta?.vertical ?? 0, 0) + } + + func testDraggingUpScrollsUp() { + var engine = ScrollEngine() + + let delta = engine.tick(offset: CGVector(dx: 0, dy: far)) + + XCTAssertGreaterThan(delta?.vertical ?? 0, 0) + } + + func testDraggingRightScrollsRight() { + var engine = ScrollEngine() + + let delta = engine.tick(offset: CGVector(dx: far, dy: 0)) + + XCTAssertLessThan(delta?.horizontal ?? 0, 0) + } + + func testDraggingLeftScrollsLeft() { + var engine = ScrollEngine() + + let delta = engine.tick(offset: CGVector(dx: -far, dy: 0)) + + XCTAssertGreaterThan(delta?.horizontal ?? 0, 0) + } + + func testReverseVerticalScrollsUpWhenDraggingDown() { + var engine = ScrollEngine() + engine.reverseVertical = true + + let delta = engine.tick(offset: CGVector(dx: 0, dy: -far)) + + XCTAssertGreaterThan(delta?.vertical ?? 0, 0) + } + + func testReverseVerticalLeavesHorizontalAlone() throws { + var reversed = ScrollEngine() + reversed.reverseVertical = true + var plain = ScrollEngine() + let drag = CGVector(dx: far, dy: -far) + + let reversedDelta = try XCTUnwrap(reversed.tick(offset: drag)) + let plainDelta = try XCTUnwrap(plain.tick(offset: drag)) + + XCTAssertEqual(reversedDelta.horizontal, plainDelta.horizontal) + XCTAssertEqual(reversedDelta.vertical, -plainDelta.vertical) + } + + func testReverseHorizontalScrollsLeftWhenDraggingRight() { + var engine = ScrollEngine() + engine.reverseHorizontal = true + + let delta = engine.tick(offset: CGVector(dx: far, dy: 0)) + + XCTAssertGreaterThan(delta?.horizontal ?? 0, 0) + } + + func testReverseHorizontalLeavesVerticalAlone() throws { + var reversed = ScrollEngine() + reversed.reverseHorizontal = true + var plain = ScrollEngine() + let drag = CGVector(dx: far, dy: -far) + + let reversedDelta = try XCTUnwrap(reversed.tick(offset: drag)) + let plainDelta = try XCTUnwrap(plain.tick(offset: drag)) + + XCTAssertEqual(reversedDelta.vertical, plainDelta.vertical) + XCTAssertEqual(reversedDelta.horizontal, -plainDelta.horizontal) + } + + func testReversingBothAxesFlipsBoth() throws { + var reversed = ScrollEngine() + reversed.reverseVertical = true + reversed.reverseHorizontal = true + var plain = ScrollEngine() + let drag = CGVector(dx: far, dy: -far) + + let reversedDelta = try XCTUnwrap(reversed.tick(offset: drag)) + let plainDelta = try XCTUnwrap(plain.tick(offset: drag)) + + XCTAssertEqual(reversedDelta.vertical, -plainDelta.vertical) + XCTAssertEqual(reversedDelta.horizontal, -plainDelta.horizontal) + } + + /// Reversing must flip the delta before the fractional remainder is added, not after. Flipping + /// the sum instead alternates the carryover's sign every tick, which stalls a slow drag at zero. + func testReversedSlowDragAccumulatesAcrossTicks() { + var engine = ScrollEngine() + engine.reverseVertical = true + // Just past the dead zone one tick is worth well under a pixel, so nothing scrolls until + // the remainder adds up over several ticks. + let crawl = CGVector(dx: 0, dy: -(engine.deadZone + 3)) + XCTAssertNil(engine.tick(offset: crawl)) + + var total: Int32 = 0 + for _ in 0..<60 { + total += engine.tick(offset: crawl)?.vertical ?? 0 + } + + XCTAssertGreaterThan(total, 0) + } + + func testOffsetInsideDeadZoneScrollsNothing() { + var engine = ScrollEngine() + + XCTAssertNil(engine.tick(offset: CGVector(dx: 0, dy: engine.deadZone - 1))) + } +}