diff --git a/Sources/Switch2KitApp/Output/MouseController.swift b/Sources/Switch2KitApp/Output/MouseController.swift index fb9f8bc..68f1e11 100644 --- a/Sources/Switch2KitApp/Output/MouseController.swift +++ b/Sources/Switch2KitApp/Output/MouseController.swift @@ -50,11 +50,14 @@ final class MouseController: @unchecked Sendable { defer { units[serial] = unit } let dx = Self.wrapDiff(state.mouseX, unit.lastX), dy = Self.wrapDiff(state.mouseY, unit.lastY) unit.lastX = state.mouseX; unit.lastY = state.mouseY - guard unit.primed else { unit.primed = true; return false } guard state.liftDistance != 0 && state.liftDistance < 1000 && state.surfaceQuality < 4000 else { + // Lost tracking breaks the counter baseline as well as fractional motion. + // Reacquisition may jump/wrap counters; its first usable report is not a delta. + unit.primed = false unit.residualX = 0; unit.residualY = 0 return false } + guard unit.primed else { unit.primed = true; return false } let scale = 0.35 * configuration.mouseSensitivity let x = Double(dx) * scale + unit.residualX, y = Double(dy) * scale + unit.residualY let moveX = x.rounded(.towardZero), moveY = y.rounded(.towardZero) diff --git a/tests/mouse/MouseTests.swift b/tests/mouse/MouseTests.swift index 0b48ebf..3f3c6c8 100644 --- a/tests/mouse/MouseTests.swift +++ b/tests/mouse/MouseTests.swift @@ -53,6 +53,48 @@ final class CGEvent { state.mouseX -= 1; precondition(!input()) } precondition(!mouse.handle(serial: "pro", model: .proController2, state: state, configuration: config)) + surfaceReacquisition() print("PASS accepted pointer motion, noise, lift/quality, wrap, disabled/denied, edge clamping and event failure") } + static func surfaceReacquisition() { + for model in [Switch2.Model.joyCon2Left, .joyCon2Right] { + let mouse = MouseController() + mouse.updateContext(permission: true, screens: []) + var config = ControllerConfiguration(); config.mouseEnabled = true + var state = ControllerState() + func input() -> Bool { + mouse.handle(serial: "surface-test", model: model, state: state, configuration: config) + } + CGEvent.cursor = CGPoint(x: 100, y: 100) + state.mouseX = 1000; state.mouseY = 2000 + // An initial no-surface report must not prime a usable counter baseline. + precondition(!input()) + state.liftDistance = 10; state.mouseX = 40000; state.mouseY = 100 + precondition(!input() && CGEvent.cursor == CGPoint(x: 100, y: 100), + "First contact after lifted reports only establishes the optical baseline") + state.mouseX += 10 + precondition(input() && CGEvent.cursor.x == 103) + for invalid in 0..<3 { + let before = CGEvent.cursor + state.liftDistance = invalid == 0 ? 0 : invalid == 1 ? 1000 : 10 + state.surfaceQuality = invalid == 2 ? 4000 : 0 + state.mouseX &+= 30000; state.mouseY &+= 30000 + precondition(!input() && CGEvent.cursor == before) + // Counters may jump, including wrapping, while tracking was unusable. + state.liftDistance = 10; state.surfaceQuality = 0 + state.mouseX = UInt16.max; state.mouseY = 500 + precondition(!input() && CGEvent.cursor == before, + "Reacquisition cannot turn an untracked interval into pointer movement") + state.mouseX = 3 + precondition(input() && CGEvent.cursor.x == before.x + 1, + "A following tracked report retains modulo counter movement") + // No fractional residue from before loss or the discarded interval survives. + state.mouseX += 1 + precondition(!input()) + } + mouse.reset() + } + print("PASS both Joy-Con halves re-prime after lift, out-of-range surface and low quality") + } + }