Skip to content
Merged
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
14 changes: 13 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -42,6 +49,11 @@ let package = Package(
linkerSettings: [
.linkedFramework("AppKit")
]
),
.testTarget(
name: "ScrollEngineTests",
dependencies: ["ScrollEngine"],
path: "Tests/ScrollEngineTests"
)
]
)
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions Sources/WinMice/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -52,6 +54,8 @@ final class AppSettings: ObservableObject {
holdToLockMode.key,
holdToStartDelayMs.key,
scrollSpeedPercent.key,
reverseScrollDirectionVertical.key,
reverseScrollDirectionHorizontal.key,
markerSize.key,
sideButtonsEnabled.key,
navigationMethod.key,
Expand Down Expand Up @@ -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) }
Expand Down
12 changes: 12 additions & 0 deletions Sources/WinMice/Settings/Panes/ScrollingPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions Sources/WinMice/main.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@preconcurrency import AppKit
@preconcurrency import ApplicationServices
import ScrollEngine

@MainActor
private final class WinMiceApp: NSObject, NSApplicationDelegate {
Expand Down Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions Tests/ScrollEngineTests/ScrollEngineTests.swift
Original file line number Diff line number Diff line change
@@ -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)))
}
}
Loading