Skip to content

Commit 15835f2

Browse files
committed
fix: clarify analog trigger precision conflicts
1 parent da25338 commit 15835f2

4 files changed

Lines changed: 138 additions & 0 deletions

File tree

XboxControllerMapper/XboxControllerMapper/Models/JoystickSettings.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ enum AnalogPrecisionTriggerMode: String, Codable, CaseIterable {
4949
case right = "right"
5050
case either = "either"
5151

52+
static var allCases: [AnalogPrecisionTriggerMode] {
53+
[.off, .right, .left, .either]
54+
}
55+
5256
var displayName: String {
5357
switch self {
5458
case .off: return "Off"

XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SettingsViews.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,14 @@ struct JoystickSettingsView: View {
193193
}
194194
}
195195
.pickerStyle(.segmented)
196+
.help("R2 / RT avoids the left-trigger swipe-typing overlap.")
197+
198+
if let analogPrecisionWarning {
199+
Label(analogPrecisionWarning, systemImage: "exclamationmark.triangle")
200+
.font(.caption)
201+
.foregroundStyle(.secondary)
202+
.help(analogPrecisionWarning)
203+
}
196204

197205
if settings.analogPrecisionTriggerMode != .off {
198206
SliderRow(
@@ -402,6 +410,40 @@ struct JoystickSettingsView: View {
402410
profileManager.updateJoystickSettings(newSettings)
403411
}
404412

413+
private var analogPrecisionTriggerButtons: [ControllerButton] {
414+
switch settings.analogPrecisionTriggerMode {
415+
case .off:
416+
return []
417+
case .left:
418+
return [.leftTrigger]
419+
case .right:
420+
return [.rightTrigger]
421+
case .either:
422+
return [.rightTrigger, .leftTrigger]
423+
}
424+
}
425+
426+
private var analogPrecisionWarning: String? {
427+
guard !analogPrecisionTriggerButtons.isEmpty else { return nil }
428+
var messages: [String] = []
429+
430+
if let profile = profileManager.activeProfile {
431+
let mappedTriggers = analogPrecisionTriggerButtons.filter { button in
432+
(profile.buttonMappings[button]?.isEmpty == false)
433+
}
434+
if !mappedTriggers.isEmpty {
435+
let names = mappedTriggers.map(\.displayName).joined(separator: " / ")
436+
messages.append("\(names) also has a button mapping; precision stacks with that action.")
437+
}
438+
}
439+
440+
if analogPrecisionTriggerButtons.contains(.leftTrigger) {
441+
messages.append("LT also starts swipe typing while the on-screen keyboard is visible.")
442+
}
443+
444+
return messages.isEmpty ? nil : messages.joined(separator: " ")
445+
}
446+
405447
/// The layer whose overrides are being edited: the picked one, or the first layer
406448
/// when nothing valid is selected (so the section always shows a layer).
407449
private func resolvedOverrideLayer(_ layers: [Layer]) -> Layer? {

XboxControllerMapper/XboxControllerMapperTests/JoystickAndMouseMappingTests.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,52 @@ final class JoystickAndMouseMappingTests: MappingEngineTestCase {
2727
}, "Mouse movement should be generated from joystick input")
2828
}
2929
}
30+
31+
func testAnalogPrecisionTriggerScalesJoystickMouseMovement() async throws {
32+
await MainActor.run {
33+
var settings = JoystickSettings()
34+
settings.leftStick.mode = .mouse
35+
settings.leftStick.mouseDeadzone = 0.05
36+
settings.leftStick.mouseAcceleration = 0.0
37+
settings.leftStick.mouseSensitivity = 0.5
38+
settings.analogPrecisionTriggerMode = .right
39+
settings.analogPrecisionMinimumSpeed = 0.25
40+
settings.analogPrecisionDeadzone = 0.0
41+
settings.analogPrecisionCurve = 0.0
42+
43+
let profile = Profile(name: "Analog Precision", buttonMappings: [:], joystickSettings: settings)
44+
profileManager.setActiveProfile(profile)
45+
controllerService.isConnected = true
46+
}
47+
await waitForTasks(0.15)
48+
49+
controllerService.updateRightTrigger(0.0, pressed: false)
50+
mockInputSimulator.clearEvents()
51+
controllerService.setLeftStickForTesting(CGPoint(x: 0.8, y: 0.0))
52+
await waitForTasks(0.35)
53+
let normal = averageRecentMouseDeltaX()
54+
55+
controllerService.updateRightTrigger(1.0, pressed: false)
56+
mockInputSimulator.clearEvents()
57+
await waitForTasks(0.35)
58+
let precise = averageRecentMouseDeltaX()
59+
60+
XCTAssertGreaterThan(normal, 0.1)
61+
XCTAssertGreaterThan(precise, 0.1)
62+
XCTAssertLessThan(precise, normal * 0.6)
63+
}
64+
65+
private func averageRecentMouseDeltaX(limit: Int = 8) -> CGFloat {
66+
let deltas = mockInputSimulator.events.compactMap { event -> CGFloat? in
67+
if case .moveMouse(let dx, _) = event {
68+
return abs(dx)
69+
}
70+
return nil
71+
}
72+
let recent = Array(deltas.suffix(limit))
73+
XCTAssertFalse(recent.isEmpty, "Expected mouse movement events")
74+
return recent.reduce(0, +) / CGFloat(max(recent.count, 1))
75+
}
3076

3177
// MARK: - Joystick Processing Tests (High Priority)
3278

XboxControllerMapper/XboxControllerMapperTests/MappingEngineTouchpadCoverageTests.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,52 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase {
117117
}
118118
}
119119

120+
func testAnalogPrecisionTriggerScalesTouchpadMouseMovement() async throws {
121+
await MainActor.run {
122+
var profile = Profile(name: "TouchAnalogPrecision", buttonMappings: [:])
123+
profile.joystickSettings.touchpadDeadzone = 0.00001
124+
profile.joystickSettings.touchpadSmoothing = 0
125+
profile.joystickSettings.analogPrecisionTriggerMode = .right
126+
profile.joystickSettings.analogPrecisionMinimumSpeed = 0.25
127+
profile.joystickSettings.analogPrecisionDeadzone = 0.0
128+
profile.joystickSettings.analogPrecisionCurve = 0.0
129+
profileManager.setActiveProfile(profile)
130+
}
131+
try? await Task.sleep(nanoseconds: 10_000_000)
132+
133+
await MainActor.run {
134+
controllerService.updateRightTrigger(0.0, pressed: false)
135+
mockInputSimulator.clearEvents()
136+
controllerService.emitInputEvent(.touchpadMoved(CGPoint(x: 0.6, y: 0.4)))
137+
}
138+
await waitForTasks(0.2)
139+
let normal = averageRecentTouchpadMouseMagnitude()
140+
141+
await MainActor.run {
142+
controllerService.updateRightTrigger(1.0, pressed: false)
143+
mockInputSimulator.clearEvents()
144+
controllerService.emitInputEvent(.touchpadMoved(CGPoint(x: 0.6, y: 0.4)))
145+
}
146+
await waitForTasks(0.2)
147+
let precise = averageRecentTouchpadMouseMagnitude()
148+
149+
XCTAssertGreaterThan(normal, 0.1)
150+
XCTAssertGreaterThan(precise, 0.1)
151+
XCTAssertLessThan(precise, normal * 0.6)
152+
}
153+
154+
private func averageRecentTouchpadMouseMagnitude(limit: Int = 4) -> CGFloat {
155+
let magnitudes = mockInputSimulator.events.compactMap { event -> CGFloat? in
156+
if case .moveMouse(let dx, let dy) = event {
157+
return hypot(dx, dy)
158+
}
159+
return nil
160+
}
161+
let recent = Array(magnitudes.suffix(limit))
162+
XCTAssertFalse(recent.isEmpty, "Expected touchpad mouse movement events")
163+
return recent.reduce(0, +) / CGFloat(max(recent.count, 1))
164+
}
165+
120166
func testTouchpadMovementSuppressedDuringTwoFingerGesture() async throws {
121167
await MainActor.run {
122168
var profile = Profile(name: "TouchSuppressed", buttonMappings: [:])

0 commit comments

Comments
 (0)