Skip to content
Closed
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
16 changes: 16 additions & 0 deletions Sources/MX3Lite/GestureMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ final class GestureMapper {
return suppressed
}

func cancelGestureForEventTapInterruption() {
guard gestureState.isActive else {
return
}

logger.log("active gesture cancelled after event tap interruption")
gestureState.end()
onGestureActivityChanged?(false)
}

#if DEBUG
var gestureStateForTesting: GestureState {
gestureState
}
#endif

private func handleButtonDown(event: CGEvent) -> Bool {
let button = Int(event.getIntegerValueField(.mouseEventButtonNumber))

Expand Down
16 changes: 12 additions & 4 deletions Sources/MX3Lite/MouseEventTap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,18 @@ final class MouseEventTap {

fileprivate func handle(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
if case .map(let mapper) = mode {
mapper.cancelGestureForEventTapInterruption()
}

let wasReenabled: Bool
if shouldBeEnabled, let machPort {
CGEvent.tapEnable(tap: machPort, enable: true)
logTapRecovery(type: type)
wasReenabled = true
} else {
wasReenabled = false
}
logTapRecovery(type: type, wasReenabled: wasReenabled)
return Unmanaged.passUnretained(event)
}

Expand All @@ -134,17 +142,17 @@ final class MouseEventTap {
}
}

private func logTapRecovery(type: CGEventType) {
private func logTapRecovery(type: CGEventType, wasReenabled: Bool) {
if let logger, logger.isEnabled {
logger.log("event tap disabled type=\(type.label); re-enabled")
logger.log("event tap disabled type=\(type.label); \(wasReenabled ? "re-enabled" : "left disabled by current state")")
return
}

guard !recoveryMessagePrinted else {
return
}
recoveryMessagePrinted = true
FileHandle.standardError.writeLine("mx3-lite: event tap was disabled; re-enabled")
FileHandle.standardError.writeLine("mx3-lite: event tap was disabled; gesture state recovered")
}
}

Expand Down
173 changes: 173 additions & 0 deletions Tests/MX3LiteTests/GestureStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,131 @@ final class GestureStateTests: XCTestCase {
XCTAssertEqual(activityChanges, [true, false])
}

func testInterruptionWhileIdleIsIdempotent() throws {
let mapper = makeMapper()
var activityChanges: [Bool] = []
mapper.onGestureActivityChanged = { activityChanges.append($0) }

mapper.cancelGestureForEventTapInterruption()
mapper.cancelGestureForEventTapInterruption()

XCTAssertEqual(activityChanges, [])
XCTAssertFalse(mapper.gestureStateForTesting.isActive)
XCTAssertFalse(mapper.handle(
type: .mouseMoved,
event: try mouseEvent(type: .mouseMoved, location: CGPoint(x: 50, y: 0))
))
}

func testInterruptionAfterGestureDownClearsStateAndDisablesMovement() throws {
let mapper = makeMapper()
var activityChanges: [Bool] = []
mapper.onGestureActivityChanged = { activityChanges.append($0) }

XCTAssertTrue(mapper.handle(
type: .otherMouseDown,
event: try buttonEvent(type: .otherMouseDown, button: 5)
))
XCTAssertTrue(mapper.gestureStateForTesting.isActive)

mapper.cancelGestureForEventTapInterruption()

XCTAssertEqual(activityChanges, [true, false])
assertInactiveAndCleared(mapper.gestureStateForTesting)
XCTAssertFalse(mapper.handle(
type: .mouseMoved,
event: try mouseEvent(type: .mouseMoved, location: CGPoint(x: 500, y: 0))
))
}

func testInterruptionClearsBelowThresholdMovementAndRepeatedNotificationIsSafe() throws {
var config = AppConfig.default
config.threshold = 220
let mapper = makeMapper(config: config)
var activityChanges: [Bool] = []
mapper.onGestureActivityChanged = { activityChanges.append($0) }

XCTAssertTrue(mapper.handle(
type: .otherMouseDown,
event: try buttonEvent(type: .otherMouseDown, button: 5)
))
XCTAssertTrue(mapper.handle(
type: .mouseMoved,
event: try mouseEvent(type: .mouseMoved, location: CGPoint(x: 100, y: 40))
))
XCTAssertEqual(mapper.gestureStateForTesting.accumulatedDx, 100)
XCTAssertEqual(mapper.gestureStateForTesting.accumulatedDy, 40)

mapper.cancelGestureForEventTapInterruption()
mapper.cancelGestureForEventTapInterruption()

XCTAssertEqual(activityChanges, [true, false])
assertInactiveAndCleared(mapper.gestureStateForTesting)
}

func testInterruptionAfterConsumedGestureAllowsImmediateNewGesture() throws {
var config = AppConfig.default
config.threshold = 10
config.downAction = .disabled
let mapper = makeMapper(config: config)
var activityChanges: [Bool] = []
mapper.onGestureActivityChanged = { activityChanges.append($0) }

XCTAssertTrue(mapper.handle(
type: .otherMouseDown,
event: try buttonEvent(type: .otherMouseDown, button: 5)
))
XCTAssertTrue(mapper.handle(
type: .mouseMoved,
event: try mouseEvent(type: .mouseMoved, location: CGPoint(x: 0, y: 11))
))
XCTAssertTrue(mapper.gestureStateForTesting.isConsumed)

mapper.cancelGestureForEventTapInterruption()
assertInactiveAndCleared(mapper.gestureStateForTesting)

XCTAssertTrue(mapper.handle(
type: .otherMouseDown,
event: try buttonEvent(
type: .otherMouseDown,
button: 5,
location: CGPoint(x: 25, y: 25)
)
))
XCTAssertTrue(mapper.handle(
type: .mouseMoved,
event: try mouseEvent(type: .mouseMoved, location: CGPoint(x: 25, y: 36))
))
XCTAssertTrue(mapper.gestureStateForTesting.isActive)
XCTAssertTrue(mapper.gestureStateForTesting.isConsumed)
XCTAssertEqual(activityChanges, [true, false, true])
}

func testRepeatedRecoveryWorkloadRemainsBounded() throws {
let mapper = makeMapper()
let down = try buttonEvent(type: .otherMouseDown, button: 5)
let movement = try mouseEvent(type: .mouseMoved, location: CGPoint(x: 1, y: 1))
var activationCount = 0
var cancellationCount = 0
mapper.onGestureActivityChanged = { isActive in
if isActive {
activationCount += 1
} else {
cancellationCount += 1
}
}

for _ in 0..<12_000 {
_ = mapper.handle(type: .otherMouseDown, event: down)
_ = mapper.handle(type: .mouseMoved, event: movement)
mapper.cancelGestureForEventTapInterruption()
}

XCTAssertEqual(activationCount, 12_000)
XCTAssertEqual(cancellationCount, 12_000)
assertInactiveAndCleared(mapper.gestureStateForTesting)
}

func testHorizontalDirectionIsInvertedWhenConfigured() {
var left = GestureState()
left.begin(at: .zero)
Expand Down Expand Up @@ -131,4 +256,52 @@ final class GestureStateTests: XCTestCase {
)
XCTAssertTrue(state.isConsumed)
}

private func makeMapper(config: AppConfig = .default) -> GestureMapper {
let logger = DebugLogger(enabled: false)
return GestureMapper(
config: config,
logger: logger,
liveEventDebug: false,
mxMasterRegistry: MXMasterDeviceRegistry(logger: logger, debug: false)
)
}

private func buttonEvent(
type: CGEventType,
button: Int,
location: CGPoint = .zero
) throws -> CGEvent {
let event = try XCTUnwrap(CGEvent(
mouseEventSource: nil,
mouseType: type,
mouseCursorPosition: location,
mouseButton: .center
))
event.setIntegerValueField(.mouseEventButtonNumber, value: Int64(button))
return event
}

private func mouseEvent(type: CGEventType, location: CGPoint) throws -> CGEvent {
try XCTUnwrap(CGEvent(
mouseEventSource: nil,
mouseType: type,
mouseCursorPosition: location,
mouseButton: .left
))
}

private func assertInactiveAndCleared(
_ state: GestureState,
file: StaticString = #filePath,
line: UInt = #line
) {
XCTAssertFalse(state.isActive, file: file, line: line)
XCTAssertFalse(state.isConsumed, file: file, line: line)
XCTAssertNil(state.lastLocation, file: file, line: line)
XCTAssertEqual(state.accumulatedDx, 0, file: file, line: line)
XCTAssertEqual(state.accumulatedDy, 0, file: file, line: line)
XCTAssertEqual(state.accumulatedScrollX, 0, file: file, line: line)
XCTAssertEqual(state.accumulatedScrollY, 0, file: file, line: line)
}
}
2 changes: 1 addition & 1 deletion docs/performance/performance-release-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ until their isolated reports are complete.

| Issue | Branch | Performance report | Isolated status | Interaction status |
| --- | --- | --- | --- | --- |
| [#21](https://github.com/KulmanD/Mx-Master-3-For-mac-lightweight-swiftBased/issues/21) | `fix/reset-interrupted-gesture` | `reset-interrupted-gesture.md` | pending | pending |
| [#21](https://github.com/KulmanD/Mx-Master-3-For-mac-lightweight-swiftBased/issues/21) | `fix/reset-interrupted-gesture` | `reset-interrupted-gesture.md` | accept | pending |
| [#22](https://github.com/KulmanD/Mx-Master-3-For-mac-lightweight-swiftBased/issues/22) | `fix/use-cgevent-movement-deltas` | `cgevent-movement-deltas.md` | pending | pending |
| [#23](https://github.com/KulmanD/Mx-Master-3-For-mac-lightweight-swiftBased/issues/23) | `fix/abort-stale-gesture-holds` | `stale-gesture-hold.md` | pending | pending |
| [#24](https://github.com/KulmanD/Mx-Master-3-For-mac-lightweight-swiftBased/issues/24) | `perf/reset-scroll-smoothing-state` | `reset-scroll-smoothing-state.md` | pending | pending |
Expand Down
Loading
Loading