Skip to content

Commit da25338

Browse files
committed
feat: add analog trigger precision scaling
1 parent 1df709c commit da25338

7 files changed

Lines changed: 239 additions & 4 deletions

File tree

XboxControllerMapper/XboxControllerMapper/Models/JoystickSettings.swift

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,36 @@ enum StickMode: String, Codable, CaseIterable {
4242
}
4343
}
4444

45+
/// Which analog trigger, if any, scales cursor speed as a continuous precision control.
46+
enum AnalogPrecisionTriggerMode: String, Codable, CaseIterable {
47+
case off = "off"
48+
case left = "left"
49+
case right = "right"
50+
case either = "either"
51+
52+
var displayName: String {
53+
switch self {
54+
case .off: return "Off"
55+
case .left: return "L2 / LT"
56+
case .right: return "R2 / RT"
57+
case .either: return "Either"
58+
}
59+
}
60+
61+
func rawValue(leftTrigger: Double, rightTrigger: Double) -> Double {
62+
switch self {
63+
case .off:
64+
return 0
65+
case .left:
66+
return leftTrigger
67+
case .right:
68+
return rightTrigger
69+
case .either:
70+
return max(leftTrigger, rightTrigger)
71+
}
72+
}
73+
}
74+
4575
/// Settings for joystick behavior.
4676
///
4777
/// Per-stick response (mode, sensitivity, acceleration, deadzone, invert, custom
@@ -114,6 +144,18 @@ struct JoystickSettings: Codable, Equatable {
114144
/// Modifier that triggers focus mode (slower mouse speed)
115145
var focusModeModifier: ModifierFlags = .command
116146

147+
/// Analog trigger that proportionally slows mouse/touchpad cursor movement.
148+
var analogPrecisionTriggerMode: AnalogPrecisionTriggerMode = .off
149+
150+
/// Cursor-speed floor at a full trigger pull (0.05 - 1.0).
151+
var analogPrecisionMinimumSpeed: Double = 0.15
152+
153+
/// Trigger value ignored before precision scaling begins (0.0 - 0.95).
154+
var analogPrecisionDeadzone: Double = 0.05
155+
156+
/// Response curve from trigger depth to speed reduction (0 = linear, 1 = cubic).
157+
var analogPrecisionCurve: Double = 0.35
158+
117159
/// Whether gyroscope aiming is enabled during focus mode.
118160
var gyroAimingEnabled: Bool = false
119161

@@ -198,6 +240,9 @@ struct JoystickSettings: Codable, Equatable {
198240
(0.5...5.0).contains(touchpadZoomToPanRatio) &&
199241
(1.0...4.0).contains(scrollBoostMultiplier) &&
200242
range.contains(focusModeSensitivity) &&
243+
(0.05...1.0).contains(analogPrecisionMinimumSpeed) &&
244+
(0.0...0.95).contains(analogPrecisionDeadzone) &&
245+
range.contains(analogPrecisionCurve) &&
201246
range.contains(gyroAimingSensitivity) &&
202247
range.contains(gyroAimingDeadzone) &&
203248
range.contains(gestureSensitivity) &&
@@ -209,6 +254,25 @@ struct JoystickSettings: Codable, Equatable {
209254
return JoystickCurves.mouseMultiplier(sensitivity: focusModeSensitivity)
210255
}
211256

257+
/// Multiplier applied to cursor motion based on analog trigger depth.
258+
/// Returns 1.0 when disabled or below deadzone, and approaches
259+
/// `analogPrecisionMinimumSpeed` at full trigger pull.
260+
func analogPrecisionMultiplier(leftTrigger: Double, rightTrigger: Double) -> Double {
261+
guard analogPrecisionTriggerMode != .off else { return 1.0 }
262+
let rawTrigger = max(0.0, min(1.0, analogPrecisionTriggerMode.rawValue(
263+
leftTrigger: leftTrigger,
264+
rightTrigger: rightTrigger
265+
)))
266+
let deadzone = max(0.0, min(0.95, analogPrecisionDeadzone))
267+
guard rawTrigger > deadzone else { return 1.0 }
268+
269+
let minimumSpeed = max(0.05, min(1.0, analogPrecisionMinimumSpeed))
270+
let normalized = (rawTrigger - deadzone) / (1.0 - deadzone)
271+
let exponent = 1.0 + max(0.0, min(1.0, analogPrecisionCurve)) * 2.0
272+
let shaped = pow(normalized, exponent)
273+
return 1.0 - shaped * (1.0 - minimumSpeed)
274+
}
275+
212276
func calibratedTouchpadValue(_ raw: Double, boost: Double) -> Double {
213277
// Calibration curve: keeps 0 and 1 stable, but maps 0.5 -> 0.5 + boost * 0.25.
214278
return min(1.0, max(0.0, raw + boost * raw * (1.0 - raw)))
@@ -260,6 +324,10 @@ extension JoystickSettings {
260324
case scrollBoostMultiplier
261325
case focusModeSensitivity
262326
case focusModeModifier
327+
case analogPrecisionTriggerMode
328+
case analogPrecisionMinimumSpeed
329+
case analogPrecisionDeadzone
330+
case analogPrecisionCurve
263331
case gyroAimingEnabled
264332
case gyroAimingSensitivity
265333
case gyroAimingDeadzone
@@ -320,6 +388,17 @@ extension JoystickSettings {
320388
scrollBoostMultiplier = try container.decode(.scrollBoostMultiplier, default: 2.0, clampedTo: 1.0...4.0)
321389
focusModeSensitivity = try container.decode(.focusModeSensitivity, default: 0.2, clampedTo: unit)
322390
focusModeModifier = try container.decode(.focusModeModifier, default: .command)
391+
analogPrecisionTriggerMode = try container.decodeLenient(
392+
.analogPrecisionTriggerMode,
393+
default: AnalogPrecisionTriggerMode.off
394+
)
395+
analogPrecisionMinimumSpeed = try container.decode(
396+
.analogPrecisionMinimumSpeed,
397+
default: 0.15,
398+
clampedTo: 0.05...1.0
399+
)
400+
analogPrecisionDeadzone = try container.decode(.analogPrecisionDeadzone, default: 0.05, clampedTo: 0.0...0.95)
401+
analogPrecisionCurve = try container.decode(.analogPrecisionCurve, default: 0.35, clampedTo: unit)
323402
gyroAimingEnabled = try container.decode(.gyroAimingEnabled, default: false)
324403
gyroAimingSensitivity = try container.decode(.gyroAimingSensitivity, default: 0.3, clampedTo: unit)
325404
gyroAimingDeadzone = try container.decode(.gyroAimingDeadzone, default: 0.3, clampedTo: unit)
@@ -361,6 +440,10 @@ extension JoystickSettings {
361440
try container.encode(scrollBoostMultiplier, forKey: .scrollBoostMultiplier)
362441
try container.encode(focusModeSensitivity, forKey: .focusModeSensitivity)
363442
try container.encode(focusModeModifier, forKey: .focusModeModifier)
443+
try container.encode(analogPrecisionTriggerMode, forKey: .analogPrecisionTriggerMode)
444+
try container.encode(analogPrecisionMinimumSpeed, forKey: .analogPrecisionMinimumSpeed)
445+
try container.encode(analogPrecisionDeadzone, forKey: .analogPrecisionDeadzone)
446+
try container.encode(analogPrecisionCurve, forKey: .analogPrecisionCurve)
364447
try container.encode(gyroAimingEnabled, forKey: .gyroAimingEnabled)
365448
try container.encode(gyroAimingSensitivity, forKey: .gyroAimingSensitivity)
366449
try container.encode(gyroAimingDeadzone, forKey: .gyroAimingDeadzone)

XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickHandler.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ extension MappingEngine {
6666
// Single lock acquisition for all controller input state (cache-friendly snapshot)
6767
let controllerSnapshot = controllerService.snapshot()
6868
let leftStick = controllerSnapshot.leftStick
69+
let analogPrecisionMultiplier = settings.analogPrecisionMultiplier(
70+
leftTrigger: Double(controllerSnapshot.leftTrigger),
71+
rightTrigger: Double(controllerSnapshot.rightTrigger)
72+
)
6973
processGyroAiming(
7074
settings: settings,
7175
now: now,
@@ -233,6 +237,7 @@ extension MappingEngine {
233237
side: .left,
234238
settings: settings,
235239
tuning: leftTuning,
240+
analogPrecisionMultiplier: analogPrecisionMultiplier,
236241
dt: dt,
237242
now: now
238243
)
@@ -251,6 +256,7 @@ extension MappingEngine {
251256
side: .right,
252257
settings: settings,
253258
tuning: rightTuning,
259+
analogPrecisionMultiplier: analogPrecisionMultiplier,
254260
dt: dt,
255261
now: now
256262
)
@@ -500,7 +506,13 @@ extension MappingEngine {
500506
}
501507
}
502508

503-
nonisolated func processMouseMovement(_ stick: CGPoint, tuning: StickTuning, settings: JoystickSettings, now: CFAbsoluteTime) {
509+
nonisolated func processMouseMovement(
510+
_ stick: CGPoint,
511+
tuning: StickTuning,
512+
settings: JoystickSettings,
513+
analogPrecisionMultiplier: Double,
514+
now: CFAbsoluteTime
515+
) {
504516
let focusFlags = settings.focusModeModifier.cgEventFlags
505517
let isFocusActive = focusFlags.rawValue != 0 && inputSimulator.isHoldingModifiers(focusFlags)
506518

@@ -515,7 +527,8 @@ extension MappingEngine {
515527
let normalizedMagnitude = JoystickMath.normalizedMagnitude(magnitude, deadzone: tuning.mouseDeadzone)
516528
let acceleratedMagnitude = pow(normalizedMagnitude, tuning.mouseAccelerationExponent)
517529

518-
let targetMultiplier = isFocusActive ? settings.focusMultiplier : tuning.mouseMultiplier
530+
let baseTargetMultiplier = isFocusActive ? settings.focusMultiplier : tuning.mouseMultiplier
531+
let targetMultiplier = baseTargetMultiplier * analogPrecisionMultiplier
519532

520533
if state.currentMultiplier == 0 {
521534
state.currentMultiplier = targetMultiplier

XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickStickStrategies.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ struct MouseStickStrategy: JoystickStickStrategy {
2121
input.stick,
2222
tuning: input.tuning,
2323
settings: input.settings,
24+
analogPrecisionMultiplier: input.analogPrecisionMultiplier,
2425
now: input.now
2526
)
2627
}

XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickStickStrategy.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ struct JoystickStickInput {
2020
/// mode from here; `settings` is for genuinely global knobs (focus mode,
2121
/// scroll boost).
2222
let tuning: StickTuning
23+
/// Continuous precision multiplier from analog trigger depth (1.0 = normal).
24+
let analogPrecisionMultiplier: Double
2325
let dt: TimeInterval
2426
let now: CFAbsoluteTime
2527
}

XboxControllerMapper/XboxControllerMapper/Services/Mapping/TouchpadInputHandler.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,19 @@ extension MappingEngine {
8484
guard magnitude > deadzone else { return }
8585

8686
let sensitivity = Config.touchpadNativeScale * settings.touchpadSensitivityMultiplier
87+
let analogPrecisionMultiplier: Double
88+
if settings.analogPrecisionTriggerMode == .off {
89+
analogPrecisionMultiplier = 1.0
90+
} else {
91+
let controllerSnapshot = controllerService.snapshot()
92+
analogPrecisionMultiplier = settings.analogPrecisionMultiplier(
93+
leftTrigger: Double(controllerSnapshot.leftTrigger),
94+
rightTrigger: Double(controllerSnapshot.rightTrigger)
95+
)
96+
}
8797

88-
let dx = Double(smoothedDelta.x) * sensitivity
89-
var dy = -Double(smoothedDelta.y) * sensitivity
98+
let dx = Double(smoothedDelta.x) * sensitivity * analogPrecisionMultiplier
99+
var dy = -Double(smoothedDelta.y) * sensitivity * analogPrecisionMultiplier
90100

91101
if settings.leftStick.invertMouseY {
92102
dy = -dy

XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SettingsViews.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,48 @@ struct JoystickSettingsView: View {
184184
}
185185
}
186186

187+
Picker("Analog Trigger", selection: Binding(
188+
get: { settings.analogPrecisionTriggerMode },
189+
set: { updateSettings(\.analogPrecisionTriggerMode, $0) }
190+
)) {
191+
ForEach(AnalogPrecisionTriggerMode.allCases, id: \.self) { mode in
192+
Text(LocalizedStringKey(mode.displayName)).tag(mode)
193+
}
194+
}
195+
.pickerStyle(.segmented)
196+
197+
if settings.analogPrecisionTriggerMode != .off {
198+
SliderRow(
199+
label: "Minimum Speed",
200+
value: Binding(
201+
get: { settings.analogPrecisionMinimumSpeed },
202+
set: { updateSettings(\.analogPrecisionMinimumSpeed, $0) }
203+
),
204+
range: 0.05...1.0,
205+
description: "Cursor speed at full trigger pull"
206+
)
207+
208+
SliderRow(
209+
label: "Trigger Deadzone",
210+
value: Binding(
211+
get: { settings.analogPrecisionDeadzone },
212+
set: { updateSettings(\.analogPrecisionDeadzone, $0) }
213+
),
214+
range: 0...0.5,
215+
description: "How far the trigger moves before slowing starts"
216+
)
217+
218+
SliderRow(
219+
label: "Trigger Curve",
220+
value: Binding(
221+
get: { settings.analogPrecisionCurve },
222+
set: { updateSettings(\.analogPrecisionCurve, $0) }
223+
),
224+
range: 0...1,
225+
description: "0 = linear, 1 = more slowdown near full pull"
226+
)
227+
}
228+
187229
Toggle("Highlight Focused Cursor", isOn: $focusCursorHighlightEnabled)
188230
.onChange(of: focusCursorHighlightEnabled) { _, newValue in
189231
FocusModeIndicator.isEnabled = newValue

XboxControllerMapper/XboxControllerMapperTests/JoystickSettingsPointerLockCodableTests.swift

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,87 @@ final class JoystickSettingsPointerLockCodableTests: XCTestCase {
3737
XCTAssertTrue(settings.isValid())
3838
}
3939
}
40+
41+
final class JoystickSettingsAnalogPrecisionCodableTests: XCTestCase {
42+
43+
func testDefaultIsOffAndLegacyDecodeKeepsNormalSpeed() throws {
44+
let settings = try JSONDecoder().decode(JoystickSettings.self, from: Data("{}".utf8))
45+
46+
XCTAssertEqual(settings.analogPrecisionTriggerMode, .off)
47+
XCTAssertEqual(settings.analogPrecisionMultiplier(leftTrigger: 1.0, rightTrigger: 1.0), 1.0)
48+
}
49+
50+
func testEncodeDecodeRoundTripAllTriggerModes() throws {
51+
for mode in AnalogPrecisionTriggerMode.allCases {
52+
var settings = JoystickSettings()
53+
settings.analogPrecisionTriggerMode = mode
54+
settings.analogPrecisionMinimumSpeed = 0.25
55+
settings.analogPrecisionDeadzone = 0.1
56+
settings.analogPrecisionCurve = 0.75
57+
58+
let data = try JSONEncoder().encode(settings)
59+
let decoded = try JSONDecoder().decode(JoystickSettings.self, from: data)
60+
61+
XCTAssertEqual(decoded.analogPrecisionTriggerMode, mode)
62+
XCTAssertEqual(decoded.analogPrecisionMinimumSpeed, 0.25)
63+
XCTAssertEqual(decoded.analogPrecisionDeadzone, 0.1)
64+
XCTAssertEqual(decoded.analogPrecisionCurve, 0.75)
65+
}
66+
}
67+
68+
func testUnknownTriggerModeDegradesToOff() throws {
69+
let json = #"{"analogPrecisionTriggerMode": "pressurePlate"}"#
70+
let settings = try JSONDecoder().decode(JoystickSettings.self, from: Data(json.utf8))
71+
72+
XCTAssertEqual(settings.analogPrecisionTriggerMode, .off)
73+
}
74+
75+
func testAnalogPrecisionMultiplierUsesSelectedTrigger() {
76+
var settings = JoystickSettings()
77+
settings.analogPrecisionTriggerMode = .left
78+
settings.analogPrecisionMinimumSpeed = 0.2
79+
settings.analogPrecisionDeadzone = 0.1
80+
settings.analogPrecisionCurve = 0.0
81+
82+
XCTAssertEqual(settings.analogPrecisionMultiplier(leftTrigger: 0.1, rightTrigger: 1.0), 1.0)
83+
XCTAssertEqual(settings.analogPrecisionMultiplier(leftTrigger: 1.0, rightTrigger: 0.0), 0.2, accuracy: 1e-10)
84+
85+
settings.analogPrecisionTriggerMode = .right
86+
XCTAssertEqual(settings.analogPrecisionMultiplier(leftTrigger: 1.0, rightTrigger: 0.1), 1.0)
87+
XCTAssertEqual(settings.analogPrecisionMultiplier(leftTrigger: 0.0, rightTrigger: 1.0), 0.2, accuracy: 1e-10)
88+
}
89+
90+
func testEitherTriggerUsesDeeperPullAndIsMonotonic() {
91+
var settings = JoystickSettings()
92+
settings.analogPrecisionTriggerMode = .either
93+
settings.analogPrecisionMinimumSpeed = 0.15
94+
settings.analogPrecisionDeadzone = 0.0
95+
settings.analogPrecisionCurve = 0.35
96+
97+
let shallow = settings.analogPrecisionMultiplier(leftTrigger: 0.2, rightTrigger: 0.1)
98+
let medium = settings.analogPrecisionMultiplier(leftTrigger: 0.2, rightTrigger: 0.5)
99+
let full = settings.analogPrecisionMultiplier(leftTrigger: 0.0, rightTrigger: 1.0)
100+
101+
XCTAssertGreaterThan(shallow, medium)
102+
XCTAssertGreaterThan(medium, full)
103+
XCTAssertEqual(full, 0.15, accuracy: 1e-10)
104+
}
105+
106+
func testDecodingClampsAnalogPrecisionRanges() throws {
107+
let json = """
108+
{
109+
"analogPrecisionTriggerMode": "either",
110+
"analogPrecisionMinimumSpeed": 0.01,
111+
"analogPrecisionDeadzone": 2.0,
112+
"analogPrecisionCurve": -1.0
113+
}
114+
"""
115+
116+
let settings = try JSONDecoder().decode(JoystickSettings.self, from: Data(json.utf8))
117+
118+
XCTAssertEqual(settings.analogPrecisionMinimumSpeed, 0.05)
119+
XCTAssertEqual(settings.analogPrecisionDeadzone, 0.95)
120+
XCTAssertEqual(settings.analogPrecisionCurve, 0.0)
121+
XCTAssertTrue(settings.isValid())
122+
}
123+
}

0 commit comments

Comments
 (0)