From 346d9362abeb69ffc281744bef2469f533961574 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Thu, 3 Sep 2026 23:45:29 +0200 Subject: [PATCH 1/6] Extract ScrollEngine into its own target with tests The autoscroll delta math had no test coverage because it lived in the WinMice executable target. Move it into a library target alongside SwipeGesturePoster so the direction mapping can be tested directly. Co-authored-by: Cursor --- Package.swift | 14 +++++- .../ScrollEngine.swift | 18 ++++--- Sources/WinMice/main.swift | 1 + .../ScrollEngineTests/ScrollEngineTests.swift | 49 +++++++++++++++++++ 4 files changed, 73 insertions(+), 9 deletions(-) rename Sources/{WinMice => ScrollEngine}/ScrollEngine.swift (86%) create mode 100644 Tests/ScrollEngineTests/ScrollEngineTests.swift 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/Sources/WinMice/ScrollEngine.swift b/Sources/ScrollEngine/ScrollEngine.swift similarity index 86% rename from Sources/WinMice/ScrollEngine.swift rename to Sources/ScrollEngine/ScrollEngine.swift index 8873f00..10036da 100644 --- a/Sources/WinMice/ScrollEngine.swift +++ b/Sources/ScrollEngine/ScrollEngine.swift @@ -11,29 +11,31 @@ 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 /// 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 + public var acceleration: CGFloat = 1.35 /// Ceiling for one tick, so a pointer flung at the edge of the screen stays controllable. - var maxDeltaPerTick: CGFloat = 130 + public var maxDeltaPerTick: CGFloat = 130 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() diff --git a/Sources/WinMice/main.swift b/Sources/WinMice/main.swift index 99b9277..1dd5657 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 { diff --git a/Tests/ScrollEngineTests/ScrollEngineTests.swift b/Tests/ScrollEngineTests/ScrollEngineTests.swift new file mode 100644 index 0000000..6d56ff5 --- /dev/null +++ b/Tests/ScrollEngineTests/ScrollEngineTests.swift @@ -0,0 +1,49 @@ +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 testOffsetInsideDeadZoneScrollsNothing() { + var engine = ScrollEngine() + + XCTAssertNil(engine.tick(offset: CGVector(dx: 0, dy: engine.deadZone - 1))) + } +} From c4208b2ea37b5f807d7e591d34a8a77db5d2f049 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Thu, 3 Sep 2026 23:46:18 +0200 Subject: [PATCH 2/6] Add a reverseVertical flag to ScrollEngine Co-authored-by: Cursor --- Sources/ScrollEngine/ScrollEngine.swift | 4 +++- .../ScrollEngineTests/ScrollEngineTests.swift | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Sources/ScrollEngine/ScrollEngine.swift b/Sources/ScrollEngine/ScrollEngine.swift index 10036da..d11dd1b 100644 --- a/Sources/ScrollEngine/ScrollEngine.swift +++ b/Sources/ScrollEngine/ScrollEngine.swift @@ -19,6 +19,8 @@ public struct ScrollEngine { public var deadZone: CGFloat = 12 /// Pixels scrolled per tick one point past the dead zone. public var speed = baseSpeed + /// Inverts the vertical wheel delta, so dragging down scrolls up. + public var reverseVertical = 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. public var acceleration: CGFloat = 1.35 @@ -45,7 +47,7 @@ public 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 vertical = (reverseVertical ? -1 : 1) * magnitude * offset.dy / distance + remainder.dy let horizontal = -magnitude * offset.dx / distance + remainder.dx let steps = CGVector(dx: horizontal.rounded(.towardZero), dy: vertical.rounded(.towardZero)) diff --git a/Tests/ScrollEngineTests/ScrollEngineTests.swift b/Tests/ScrollEngineTests/ScrollEngineTests.swift index 6d56ff5..191b730 100644 --- a/Tests/ScrollEngineTests/ScrollEngineTests.swift +++ b/Tests/ScrollEngineTests/ScrollEngineTests.swift @@ -41,6 +41,28 @@ final class ScrollEngineTests: XCTestCase { 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() { + var reversed = ScrollEngine() + reversed.reverseVertical = true + var plain = ScrollEngine() + let drag = CGVector(dx: far, dy: -far) + + let reversedDelta = reversed.tick(offset: drag) + let plainDelta = plain.tick(offset: drag) + + XCTAssertEqual(reversedDelta?.horizontal, plainDelta?.horizontal) + XCTAssertEqual(reversedDelta?.vertical, plainDelta.map { -$0.vertical }) + } + func testOffsetInsideDeadZoneScrollsNothing() { var engine = ScrollEngine() From 868827a0e8be04e9e2f12d1877e4921b8519186c Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Thu, 3 Sep 2026 23:47:04 +0200 Subject: [PATCH 3/6] Add a reverseHorizontal flag to ScrollEngine Co-authored-by: Cursor --- Sources/ScrollEngine/ScrollEngine.swift | 8 +++++-- .../ScrollEngineTests/ScrollEngineTests.swift | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Sources/ScrollEngine/ScrollEngine.swift b/Sources/ScrollEngine/ScrollEngine.swift index d11dd1b..02aa1a3 100644 --- a/Sources/ScrollEngine/ScrollEngine.swift +++ b/Sources/ScrollEngine/ScrollEngine.swift @@ -21,6 +21,8 @@ public struct ScrollEngine { 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. public var acceleration: CGFloat = 1.35 @@ -46,9 +48,11 @@ public 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. + // 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 = -magnitude * offset.dx / distance + remainder.dx + 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/Tests/ScrollEngineTests/ScrollEngineTests.swift b/Tests/ScrollEngineTests/ScrollEngineTests.swift index 191b730..1549e81 100644 --- a/Tests/ScrollEngineTests/ScrollEngineTests.swift +++ b/Tests/ScrollEngineTests/ScrollEngineTests.swift @@ -63,6 +63,28 @@ final class ScrollEngineTests: XCTestCase { XCTAssertEqual(reversedDelta?.vertical, plainDelta.map { -$0.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() { + var reversed = ScrollEngine() + reversed.reverseHorizontal = true + var plain = ScrollEngine() + let drag = CGVector(dx: far, dy: -far) + + let reversedDelta = reversed.tick(offset: drag) + let plainDelta = plain.tick(offset: drag) + + XCTAssertEqual(reversedDelta?.vertical, plainDelta?.vertical) + XCTAssertEqual(reversedDelta?.horizontal, plainDelta.map { -$0.horizontal }) + } + func testOffsetInsideDeadZoneScrollsNothing() { var engine = ScrollEngine() From 20b998834e1249af1313bd7d40970b48f4ce59ab Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Thu, 3 Sep 2026 23:47:30 +0200 Subject: [PATCH 4/6] Cover the reversed sub-pixel carryover on slow drags Co-authored-by: Cursor --- .../ScrollEngineTests/ScrollEngineTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Tests/ScrollEngineTests/ScrollEngineTests.swift b/Tests/ScrollEngineTests/ScrollEngineTests.swift index 1549e81..c8cff4f 100644 --- a/Tests/ScrollEngineTests/ScrollEngineTests.swift +++ b/Tests/ScrollEngineTests/ScrollEngineTests.swift @@ -85,6 +85,24 @@ final class ScrollEngineTests: XCTestCase { XCTAssertEqual(reversedDelta?.horizontal, plainDelta.map { -$0.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() From 3983db7938d170436ae343aaba2b0ee790486015 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Thu, 3 Sep 2026 23:48:56 +0200 Subject: [PATCH 5/6] Add a setting to reverse autoscroll direction Requested in #1: dragging down scrolled the opposite way to what some users expect. Both axes are exposed independently and default to off, so existing installs are unaffected. Co-authored-by: Cursor --- README.md | 1 + Sources/WinMice/AppSettings.swift | 14 ++++++++++++++ Sources/WinMice/Settings/Panes/ScrollingPane.swift | 12 ++++++++++++ Sources/WinMice/main.swift | 2 ++ 4 files changed, 29 insertions(+) diff --git a/README.md b/README.md index 4d05c41..2ddc4ee 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ 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 / horizontal** — invert either axis independently, so dragging down scrolls up. - 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/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 1dd5657..bcf9856 100644 --- a/Sources/WinMice/main.swift +++ b/Sources/WinMice/main.swift @@ -269,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 From e559ceb0478109a49225ef0118720b5aec66cec1 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 00:01:59 +0200 Subject: [PATCH 6/6] Apply review feedback on the reverse-direction setting Keep the wheel2 baseline inversion separate from the user's reverse flag so that "reverse means -1" reads uniformly across both axes, unwrap the deltas in the cross-axis tests so they cannot pass vacuously, cover both flags at once, and narrow two engine knobs that no consumer reads. Co-authored-by: Cursor --- README.md | 3 +- Sources/ScrollEngine/ScrollEngine.swift | 6 ++-- .../ScrollEngineTests/ScrollEngineTests.swift | 34 +++++++++++++------ 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2ddc4ee..e3d3a20 100644 --- a/README.md +++ b/README.md @@ -43,7 +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 / horizontal** — invert either axis independently, so dragging down scrolls up. +- **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/ScrollEngine/ScrollEngine.swift b/Sources/ScrollEngine/ScrollEngine.swift index 02aa1a3..83cc9dd 100644 --- a/Sources/ScrollEngine/ScrollEngine.swift +++ b/Sources/ScrollEngine/ScrollEngine.swift @@ -25,9 +25,9 @@ public struct ScrollEngine { 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. - public var acceleration: CGFloat = 1.35 + var acceleration: CGFloat = 1.35 /// Ceiling for one tick, so a pointer flung at the edge of the screen stays controllable. - public var maxDeltaPerTick: CGFloat = 130 + var maxDeltaPerTick: CGFloat = 130 private var remainder = CGVector.zero @@ -52,7 +52,7 @@ public struct ScrollEngine { // 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 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/Tests/ScrollEngineTests/ScrollEngineTests.swift b/Tests/ScrollEngineTests/ScrollEngineTests.swift index c8cff4f..17f958f 100644 --- a/Tests/ScrollEngineTests/ScrollEngineTests.swift +++ b/Tests/ScrollEngineTests/ScrollEngineTests.swift @@ -50,17 +50,17 @@ final class ScrollEngineTests: XCTestCase { XCTAssertGreaterThan(delta?.vertical ?? 0, 0) } - func testReverseVerticalLeavesHorizontalAlone() { + func testReverseVerticalLeavesHorizontalAlone() throws { var reversed = ScrollEngine() reversed.reverseVertical = true var plain = ScrollEngine() let drag = CGVector(dx: far, dy: -far) - let reversedDelta = reversed.tick(offset: drag) - let plainDelta = plain.tick(offset: drag) + 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.map { -$0.vertical }) + XCTAssertEqual(reversedDelta.horizontal, plainDelta.horizontal) + XCTAssertEqual(reversedDelta.vertical, -plainDelta.vertical) } func testReverseHorizontalScrollsLeftWhenDraggingRight() { @@ -72,17 +72,31 @@ final class ScrollEngineTests: XCTestCase { XCTAssertGreaterThan(delta?.horizontal ?? 0, 0) } - func testReverseHorizontalLeavesVerticalAlone() { + func testReverseHorizontalLeavesVerticalAlone() throws { var reversed = ScrollEngine() reversed.reverseHorizontal = true var plain = ScrollEngine() let drag = CGVector(dx: far, dy: -far) - let reversedDelta = reversed.tick(offset: drag) - let plainDelta = plain.tick(offset: drag) + 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.map { -$0.horizontal }) + 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