From fcae7d14bada476c87847b3e5d5a7ca949c9b932 Mon Sep 17 00:00:00 2001 From: Haruki Fukui Date: Sun, 12 Jul 2026 05:33:07 +0900 Subject: [PATCH 1/3] Prepare PointerGestureKit 0.2.0 --- .editorconfig | 14 +- .github/ISSUE_TEMPLATE/bug_report.yml | 70 -- .github/ISSUE_TEMPLATE/config.yml | 5 - .github/ISSUE_TEMPLATE/feature_request.yml | 52 -- .github/pull_request_template.md | 19 +- .github/workflows/ci.yml | 4 +- .gitignore | 11 + .swift-format | 55 +- CHANGELOG.md | 60 ++ CONTRIBUTING.md | 44 +- Makefile | 38 +- Package.swift | 2 +- README.md | 235 ++++-- SECURITY.md | 38 +- .../Events/GestureEventDisposition.swift | 16 +- .../Events/GestureEventSource.swift | 5 +- .../Events/GestureInputEvent.swift | 6 +- .../Events/GestureModifierFlags.swift | 20 +- .../Events/GestureReplayRequest.swift | 42 +- .../Events/PointerButton.swift | 24 +- .../Geometry/GestureDirection.swift | 2 +- .../GestureMovementResolver.swift | 6 +- .../Geometry/GesturePoint.swift | 15 +- .../Matching/GesturePatternCatalog.swift | 80 -- .../Matching/GesturePatternMatcher.swift | 20 +- .../Observation/GestureObservationToken.swift | 20 + .../GestureRecognizer+Observation.swift | 161 ++++ .../GestureRecognizerObservationCenter.swift | 130 +++ .../PointerGestureKit.md | 193 +++-- .../Recognizer/GestureObservationToken.swift | 36 - .../GestureRecognitionContext.swift | 6 +- .../GestureRecognizer+ButtonInputReplay.swift | 129 +++ ...estureRecognizer+ConsumedButtonInput.swift | 61 -- .../GestureRecognizer+EventHandling.swift | 92 ++- .../GestureRecognizer+Lifecycle.swift | 42 +- .../GestureRecognizer+Observation.swift | 157 ---- .../GestureRecognizer+RecognitionPolicy.swift | 12 +- .../GestureRecognizer+Session.swift | 79 +- .../GestureRecognizer+SessionExpiration.swift | 46 +- ...GestureRecognizer+SessionProgression.swift | 72 -- ...t => GestureRecognizer+SessionState.swift} | 6 +- .../GestureRecognizer+TraceProgression.swift | 99 +++ .../Recognizer/GestureRecognizer.swift | 183 ++--- .../Recognizer/GestureRecognizerCleanup.swift | 25 + .../GestureRecognizerConfiguration.swift | 25 +- .../Recognizer/GestureRecognizerFailure.swift | 9 +- .../Recognizer/GestureRecognizerState.swift | 40 +- ...ng.swift => GestureRecognizerTiming.swift} | 6 +- .../Recognizer/GestureRecognizerTuning.swift | 123 +-- .../GestureTraceEndpointNormalizer.swift | 91 --- .../PointerButton+CoreGraphics.swift | 20 +- .../GestureEventTap+EventMapping.swift | 28 +- .../EventTap/GestureEventTap+Replay.swift | 97 ++- .../EventTap/GestureEventTap.swift | 95 +-- .../EventTap/SyntheticEventSuppression.swift | 44 +- .../PointerGestureKitCoreGraphics.md | 92 +-- .../GesturePointConversionTests.swift | 18 +- .../PointerButtonConversionTests.swift | 89 +-- .../GestureEventTapEventMappingTests.swift | 236 +++--- ...reEventTapReplayPointCoalescingTests.swift | 48 ++ .../EventTap/GestureEventTapTests.swift | 41 - .../EventTap/GestureEventTapUsageTests.swift | 141 +++- .../SyntheticEventSuppressionTests.swift | 70 +- ...GestureKitCoreGraphicsPublicAPITests.swift | 35 + .../Events/GestureEventDispositionTests.swift | 27 +- .../Events/GestureInputEventTests.swift | 135 +--- .../Events/GestureModifierFlagsTests.swift | 25 +- .../Events/GestureReplayRequestTests.swift | 136 ++-- .../Events/PointerButtonTests.swift | 55 +- .../Geometry/GestureDirectionTests.swift | 10 - .../GestureMovementResolverTests.swift | 100 +++ .../Geometry/GesturePointTests.swift | 20 +- .../Matching/GesturePatternCatalogTests.swift | 191 ----- .../Matching/GesturePatternMatcherTests.swift | 76 +- .../GestureObservationTokenTests.swift | 38 + ...eRecognizerObservationFilteringTests.swift | 749 ++++++++++++++++++ .../GestureRecognizerObservationTests.swift | 106 ++- .../PointerGestureKitPublicAPITests.swift | 267 +++++++ ...PointerGestureKitSourceBoundaryTests.swift | 42 + .../GestureMovementResolverTests.swift | 103 --- .../GestureObservationTokenTests.swift | 109 --- .../GestureRecognitionContextTests.swift | 32 +- .../GestureRecognizerBasicUsageTests.swift | 44 +- .../GestureRecognizerButtonClickTests.swift | 149 +++- .../GestureRecognizerCancelEventTests.swift | 31 +- .../GestureRecognizerCancellationTests.swift | 64 +- .../GestureRecognizerConfigurationTests.swift | 184 ++--- .../GestureRecognizerContextPolicyTests.swift | 28 +- .../GestureRecognizerDeinitTests.swift | 15 +- .../GestureRecognizerMatchingTests.swift | 375 ++++++++- ...GestureRecognizerModifierPolicyTests.swift | 32 +- ...eRecognizerObservationFilteringTests.swift | 112 --- .../GestureRecognizerRecordingModeTests.swift | 153 +++- .../GestureRecognizerReplayTests.swift | 248 +++++- ...tureRecognizerSessionExpirationTests.swift | 135 +++- .../GestureRecognizerStartStopTests.swift | 72 +- .../GestureRecognizerStartupRetryTests.swift | 66 +- ...GestureRecognizerStatePublicAPITests.swift | 183 ----- .../GestureRecognizerStateTests.swift | 120 +++ .../GestureRecognizerTimingTests.swift | 22 +- .../GestureRecognizerTuningTests.swift | 192 +++-- .../GestureTraceEndpointNormalizerTests.swift | 226 ------ .../GestureEventDispositionAssertions.swift | 11 + .../GestureRecognizerTestConfiguration.swift | 2 + .../GestureRecognizerTuning+TestSupport.swift | 2 +- .../GestureReplayRequestAssertions.swift | 11 + 106 files changed, 5115 insertions(+), 3363 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml rename Sources/PointerGestureKit/{Recognizer => Geometry}/GestureMovementResolver.swift (74%) delete mode 100644 Sources/PointerGestureKit/Matching/GesturePatternCatalog.swift create mode 100644 Sources/PointerGestureKit/Observation/GestureObservationToken.swift create mode 100644 Sources/PointerGestureKit/Observation/GestureRecognizer+Observation.swift create mode 100644 Sources/PointerGestureKit/Observation/GestureRecognizerObservationCenter.swift delete mode 100644 Sources/PointerGestureKit/Recognizer/GestureObservationToken.swift create mode 100644 Sources/PointerGestureKit/Recognizer/GestureRecognizer+ButtonInputReplay.swift delete mode 100644 Sources/PointerGestureKit/Recognizer/GestureRecognizer+ConsumedButtonInput.swift delete mode 100644 Sources/PointerGestureKit/Recognizer/GestureRecognizer+Observation.swift delete mode 100644 Sources/PointerGestureKit/Recognizer/GestureRecognizer+SessionProgression.swift rename Sources/PointerGestureKit/Recognizer/{GestureRecognizer+InternalState.swift => GestureRecognizer+SessionState.swift} (85%) create mode 100644 Sources/PointerGestureKit/Recognizer/GestureRecognizer+TraceProgression.swift create mode 100644 Sources/PointerGestureKit/Recognizer/GestureRecognizerCleanup.swift rename Sources/PointerGestureKit/Recognizer/{GestureRecognizer+Timing.swift => GestureRecognizerTiming.swift} (69%) delete mode 100644 Sources/PointerGestureKit/Recognizer/GestureTraceEndpointNormalizer.swift create mode 100644 Tests/PointerGestureKitCoreGraphicsTests/EventTap/GestureEventTapReplayPointCoalescingTests.swift delete mode 100644 Tests/PointerGestureKitCoreGraphicsTests/EventTap/GestureEventTapTests.swift create mode 100644 Tests/PointerGestureKitCoreGraphicsTests/PointerGestureKitCoreGraphicsPublicAPITests.swift create mode 100644 Tests/PointerGestureKitTests/Geometry/GestureMovementResolverTests.swift delete mode 100644 Tests/PointerGestureKitTests/Matching/GesturePatternCatalogTests.swift create mode 100644 Tests/PointerGestureKitTests/Observation/GestureObservationTokenTests.swift create mode 100644 Tests/PointerGestureKitTests/Observation/GestureRecognizerObservationFilteringTests.swift rename Tests/PointerGestureKitTests/{Recognizer => Observation}/GestureRecognizerObservationTests.swift (72%) create mode 100644 Tests/PointerGestureKitTests/PointerGestureKitPublicAPITests.swift create mode 100644 Tests/PointerGestureKitTests/PointerGestureKitSourceBoundaryTests.swift delete mode 100644 Tests/PointerGestureKitTests/Recognizer/GestureMovementResolverTests.swift delete mode 100644 Tests/PointerGestureKitTests/Recognizer/GestureObservationTokenTests.swift delete mode 100644 Tests/PointerGestureKitTests/Recognizer/GestureRecognizerObservationFilteringTests.swift delete mode 100644 Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStatePublicAPITests.swift create mode 100644 Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStateTests.swift delete mode 100644 Tests/PointerGestureKitTests/Recognizer/GestureTraceEndpointNormalizerTests.swift create mode 100644 Tests/PointerGestureKitTests/Support/GestureEventDispositionAssertions.swift create mode 100644 Tests/PointerGestureKitTests/Support/GestureReplayRequestAssertions.swift diff --git a/.editorconfig b/.editorconfig index fe52efb..2aab0a2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,20 +3,10 @@ root = true [*] charset = utf-8 end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.swift] -indent_style = space -indent_size = 2 - -[*.{md,yml,yaml,json}] -indent_style = space -indent_size = 2 - -[.swift-format] indent_style = space indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true [Makefile] indent_style = tab diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index a675604..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Bug report -description: Report a reproducible PointerGestureKit issue. -title: "[Bug]: " -labels: ["bug"] -body: - - type: markdown - attributes: - value: >- - Do not include vulnerability details, secrets, tokens, personal data, private logs, - local paths, private app metadata, or app-specific internal references. Use SECURITY.md - for security reports. - - type: textarea - id: summary - attributes: - label: Summary - description: Describe the issue in one or two sentences. - validations: - required: true - - type: textarea - id: environment - attributes: - label: Environment - description: >- - Include package, macOS, toolchain, host-app, permission, and input-device details. - placeholder: | - - PointerGestureKit version or commit: - - macOS version: - - Swift or Xcode version: - - Product: PointerGestureKit / PointerGestureKitCoreGraphics - - Host app sandboxed: yes/no - - Accessibility permission: granted / denied / unknown - - Input device: mouse / trackpad / other - - Reproducibility: always / sometimes / once - validations: - required: true - - type: textarea - id: reproduction - attributes: - label: Reproduction - description: >- - Provide the smallest code sample, test case, or focused steps that reproduce the issue. - placeholder: | - 1. - 2. - 3. - validations: - required: true - - type: textarea - id: expected - attributes: - label: Expected behavior - description: >- - Describe the gesture state, event disposition, callback, or public API value you - expected. - validations: - required: true - - type: textarea - id: actual - attributes: - label: Actual behavior - description: >- - Include the exact public error, state snapshot, callback behavior, assertion, or visible - behavior. Redact sensitive values. - validations: - required: true - - type: textarea - id: additional_context - attributes: - label: Additional context - description: Add any other public, non-sensitive context that helps explain the issue. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 369672c..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Security reports - url: https://github.com/naviapps/pointer-gesture-kit/security/policy - about: Report vulnerabilities privately instead of opening a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 7cc45f3..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Feature request -description: Propose a focused PointerGestureKit improvement. -title: "[Feature]: " -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: >- - Do not include vulnerability details, secrets, tokens, personal data, private logs, - local paths, private app metadata, or app-specific internal references. - - type: textarea - id: summary - attributes: - label: Summary - description: Describe the requested improvement in one or two sentences. - validations: - required: true - - type: textarea - id: use_case - attributes: - label: Use case - description: Describe the host-app or library integration problem this would solve. - validations: - required: true - - type: textarea - id: scope - attributes: - label: Scope - description: >- - Explain why this belongs in PointerGestureKit's gesture recognition, event source, Core - Graphics integration, observation, or documentation rather than the host app. - validations: - required: true - - type: textarea - id: proposal - attributes: - label: Proposal - description: Describe the API, behavior, documentation, tests, or packaging change you want. - validations: - required: true - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: >- - Describe host-app workarounds, separate package designs, or existing APIs you - considered. - - type: textarea - id: additional_context - attributes: - label: Additional context - description: Add any other public, non-sensitive context that helps explain the request. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b210aed..520fdeb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,24 +2,13 @@ - -## Scope - -- - ## Validation - [ ] `make check` -- [ ] Tests added or updated for behavior changes, or not needed -- [ ] Documentation or `CHANGELOG.md` updated if needed +- [ ] Documentation or `CHANGELOG.md` updated if public behavior changed ## Checklist -- [ ] Public API changes are intentional and documented. -- [ ] PointerGestureKit scope and host-app responsibility boundaries are respected. -- [ ] No generated build output, dependency caches, local state, secrets, tokens, private keys, - personal data, local paths, private app metadata, or app-specific internal references are - included. - -## Notes - -- +- [ ] Public API changes are intentional. +- [ ] Host-app responsibilities stay outside this package. +- [ ] No generated output, dependency caches, local state, secrets, private logs, local paths, or app-internal references are included. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cbbb4d..5f09abc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,12 @@ jobs: name: SwiftPM runs-on: macos-15 timeout-minutes: 15 + env: + DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: persist-credentials: false diff --git a/.gitignore b/.gitignore index 268946b..031f0e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,15 @@ .build/ .swiftpm/ .DS_Store +.envrc +.env +.env.* +!.env.example Package.resolved +*.doccarchive +DerivedData/ +xcuserdata/ +*.xcodeproj +*.xcworkspace +*.xcresult +*.log diff --git a/.swift-format b/.swift-format index d7c1122..0f54008 100644 --- a/.swift-format +++ b/.swift-format @@ -1,11 +1,54 @@ { + "indentation": { + "spaces": 2 + }, + "lineLength": 100, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, "rules": { - "AllPublicDeclarationsHaveDocumentation": true, - "BeginDocumentationCommentWithOneLineSummary": true, - "NeverForceUnwrap": true, - "NeverUseForceTry": true, - "NeverUseImplicitlyUnwrappedOptionals": true, - "ValidateDocumentationComments": true + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "AlwaysUseLowerCamelCase": false, + "AmbiguousTrailingClosureOverload": false, + "AvoidRetroactiveConformances": false, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": false, + "DontRepeatTypeInStaticProperties": false, + "FileScopedDeclarationPrivacy": false, + "FullyIndirectEnum": false, + "GroupNumericLiterals": false, + "IdentifiersMustBeASCII": false, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": false, + "NoAssignmentInExpressions": false, + "NoBlockComments": false, + "NoCasesWithOnlyFallthrough": false, + "NoEmptyLinesOpeningClosingBraces": false, + "NoEmptyTrailingClosureParentheses": false, + "NoLabelsInCasePatterns": false, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": false, + "NoPlaygroundLiterals": false, + "NoVoidReturnOnFunctionSignature": false, + "OmitExplicitReturns": false, + "OneCasePerLine": false, + "OneVariableDeclarationPerLine": false, + "OnlyOneTrailingClosureArgument": false, + "OrderedImports": false, + "ReplaceForEachWithForLoop": false, + "ReturnVoidInsteadOfEmptyTuple": false, + "TypeNamesShouldBeCapitalized": false, + "UseEarlyExits": false, + "UseExplicitNilCheckInConditions": false, + "UseLetInEveryBoundCaseVariable": false, + "UseShorthandTypeNames": false, + "UseSingleLinePropertyGetter": false, + "UseSynthesizedInitializer": false, + "UseTripleSlashForDocumentationComments": false, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false }, "version": 1 } diff --git a/CHANGELOG.md b/CHANGELOG.md index b32dc8e..07fd89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,66 @@ Released versions follow semantic versioning. No changes yet. +## [0.2.0] - 2026-07-12 + +### Changed + +- Raised the package requirement to Swift 6.2 for actor-isolated resource cleanup. +- Moved recognizer deinitialization cleanup into a non-generic lifetime owner so optimized Release + builds preserve replay and event-source shutdown behavior without triggering a compiler crash. +- Updated installation guidance to use the 0.2.0 release line. +- Removed the redundant `GesturePatternCatalog` and `GesturePatternValidator` wrappers. Pattern + registration reports an empty pattern directly; duplicate registration intentionally replaces + the previous match, while settings-level conflict policy remains with the host. +- Recording mode now records directions without requesting a matcher, while + still respecting `isRecognitionEnabled` for the active context. +- `GestureObservationToken` is main-actor isolated and exposes one synchronous, idempotent + `cancel()` operation. +- `GestureRecognizerTuning.standard` provides production defaults, while + `validated(...)` rejects invalid custom values instead of silently changing behavior. +- `GestureRecognizerState.Trace` now exposes `tailPoint` for low-latency live + rendering without publishing the full raw-point history on every pointer move. +- `GestureRecognizerConfiguration` now accepts `passesThroughEmptyMatcher` so hosts can leave + pointer input untouched when the active matcher contains no patterns. +- `PointerButton.additionalButtonNumber` and its initializer are now named `auxiliaryButtonID` + to match the package's primary, secondary, middle, and auxiliary button terminology. +- `GestureEventSource` is main-actor isolated instead of requiring `Sendable`, matching its UI + event-loop lifecycle and delivery contract. +- Replay requests now include `dragStart(button:points:)` for replaying a consumed button-down and + drag prefix when recognition stops before the physical button is released. +- Core Graphics replay docs now describe empty and single-point drag replay + behavior and replayed-event suppression. +- `GesturePatternMatcher.register(pattern:match:)` returns whether the non-empty pattern was + registered. +- `GesturePatternMatcher.match(pattern:)` is available for exact-pattern lookup + in host-app tests. +- `GestureEventDisposition` now exposes whether the original platform event + should be consumed through `consumesOriginalEvent`. +- `GestureInputEvent.Kind.buttonDragged(_:)` is now + `GestureInputEvent.Kind.buttonMoved(_:)` so the platform-neutral input model + describes pointer movement instead of Core Graphics dragged-event naming. +- Recognizer lifecycle, failure, and event-disposition values are finite enums + while observable state, input, replay, context, and geometry remain comparable + value models. +- Added README links to release notes. +- Clarified that security updates target the latest released version. + +### Removed + +- Removed `GestureEventDisposition` `CaseIterable`/`allCases` conformance so + event-disposition policy exposes only the returned action and + `consumesOriginalEvent` decision instead of a redundant enumeration helper. +- Removed the `GestureInputEvent.Kind.buttonDragged(_:)` spelling; no alias is + kept because platform-specific dragged terminology belongs in adapters. +- Removed `GestureDirection` `CaseIterable`/`allCases` conformance so the + public geometry contract is the four direction cases themselves rather than + an ordered collection helper. +- Removed `GesturePatternValidator`; settings-level duplicate policy is outside matching. +- Removed public replay-request convenience properties that duplicated enum + payload pattern matching. +- Removed `Hashable` from observable input, replay, context, geometry, tuning, and recognizer-state + values whose public contract only requires equality and concurrency safety. + ## [0.1.0] - 2026-05-29 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7e9134..c554b0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,49 +1,13 @@ # Contributing -Thank you for your interest in improving PointerGestureKit. - -## Scope - -PointerGestureKit focuses on macOS pointer-gesture recognition, direction matching, recognizer -state observation, and the macOS Core Graphics event-tap adapter. Host-app concerns such as -command catalogs, overlay UI, Accessibility onboarding copy, persistence, syncing, telemetry, and -action execution should stay outside this package. - -Keep changes focused. Avoid bundling unrelated refactors, formatting-only rewrites, and behavior -changes in the same pull request. - -## Development - -Local development commands use `make`. - -Run the full local check before opening a pull request: +Run the package check before submitting changes: ```sh make check ``` -For focused local work, run checks separately: - -```sh -make format -make lint -make test -make build -make docc -``` - -## Pull Requests - -Before submitting a pull request: - -- Keep the public API surface minimal and documented. -- Add or update tests for behavior changes. -- Update README, DocC, or `CHANGELOG.md` when user-facing behavior changes. -- Do not commit generated build output, dependency caches, editor state, local tool state, secrets, - tokens, private keys, personal data, local paths, private app metadata, or app-specific internal - references. +Keep changes focused. Update README.md or CHANGELOG.md when public behavior changes. -## Security +Do not commit generated output, dependency caches, local tool state, secrets, tokens, private keys, personal data, local paths, private app metadata, or app-specific internal references. -Do not report vulnerabilities in public issues, pull requests, or discussions. Follow -[SECURITY.md](SECURITY.md) instead. +Report vulnerabilities privately. See SECURITY.md. diff --git a/Makefile b/Makefile index 8ee0c3a..dc0b800 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,17 @@ SWIFT_FORMAT_PATHS := Sources Tests Package.swift -SWIFT_TEST_FLAGS := -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors -SWIFT_BUILD_FLAGS := $(SWIFT_TEST_FLAGS) -SWIFT_DOCC_MODULES := PointerGestureKit PointerGestureKitCoreGraphics -SWIFT_DOCC_OUTPUT_DIR := .build/docc -SWIFT_DOCC_BUNDLE_VERSION := 1.0.0 -SWIFT_SYMBOL_GRAPH_DIR := .build/$(shell swift -print-target-info | awk -F\" '/"unversionedTriple"/ { print $$4; exit }')/symbolgraph +SWIFT_STRICT_FLAGS := -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors -.DEFAULT_GOAL := help +.DEFAULT_GOAL := check -.PHONY: help check format lint test build docc +.PHONY: check build build-release format lint test -help: - @awk -F: '/^[a-zA-Z0-9_-]+:/ { print $$1 }' $(MAKEFILE_LIST) +check: lint build build-release test -check: lint test build docc +build: + swift build $(SWIFT_STRICT_FLAGS) + +build-release: + swift build -c release $(SWIFT_STRICT_FLAGS) format: swift format format --recursive --in-place $(SWIFT_FORMAT_PATHS) @@ -22,20 +20,4 @@ lint: swift format lint --recursive --strict $(SWIFT_FORMAT_PATHS) test: - swift test $(SWIFT_TEST_FLAGS) - -build: - swift build $(SWIFT_BUILD_FLAGS) - -docc: build - @mkdir -p $(SWIFT_DOCC_OUTPUT_DIR) - swift package dump-symbol-graph --minimum-access-level public - @for module in $(SWIFT_DOCC_MODULES); do \ - xcrun docc convert Sources/$$module/$$module.docc \ - --additional-symbol-graph-dir $(SWIFT_SYMBOL_GRAPH_DIR) \ - --output-dir $(SWIFT_DOCC_OUTPUT_DIR)/$$module.doccarchive \ - --fallback-display-name $$module \ - --fallback-bundle-identifier com.naviapps.$$module \ - --fallback-bundle-version $(SWIFT_DOCC_BUNDLE_VERSION) \ - --warnings-as-errors || exit $$?; \ - done + swift test $(SWIFT_STRICT_FLAGS) diff --git a/Package.swift b/Package.swift index d19481d..d7698ed 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 import PackageDescription let package = Package( diff --git a/README.md b/README.md index 204d330..4ffccc3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PointerGestureKit -[![CI](https://github.com/naviapps/pointer-gesture-kit/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/naviapps/pointer-gesture-kit/actions/workflows/ci.yml) +[![CI](https://github.com/naviapps/pointer-gesture-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/naviapps/pointer-gesture-kit/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Swift versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fnaviapps%2Fpointer-gesture-kit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/naviapps/pointer-gesture-kit) [![Supported platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fnaviapps%2Fpointer-gesture-kit%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/naviapps/pointer-gesture-kit) @@ -8,12 +8,13 @@ PointerGestureKit recognizes pointer-button drag gestures on macOS. It provides a platform-neutral recognizer core plus a Core Graphics event-tap adapter for apps that want to turn pointer-button drag paths into app-owned command matches. It defaults to the -secondary button and can be configured for primary, middle, or additional pointer buttons. +secondary button and can be configured for primary, middle, or auxiliary pointer buttons. Gesture movement is recorded as one of four cardinal directions: up, down, left, and right. Movement without a dominant axis does not add a direction. `GesturePoint` values use gesture coordinates where positive vertical movement resolves as down. +The recognizer passes through input with non-finite coordinates. -The core `PointerGestureKit` product owns direction recognition, matching, gesture-session state, +The core `PointerGestureKit` product owns direction recognition, matching, gesture-input state, observation, event-disposition modeling, event-source start retry behavior, and platform-neutral event-source contracts. The `PointerGestureKitCoreGraphics` product owns the live macOS Core Graphics event-tap bridge and replay adapter. Host apps own commands, UI, Accessibility permission @@ -32,14 +33,14 @@ flowchart LR ## Requirements - macOS 14 or later -- Swift 6.0 or later +- Swift 6.2 or later ## Installation Add this package to your Swift Package dependencies: ```swift -.package(url: "https://github.com/naviapps/pointer-gesture-kit.git", from: "0.1.0") +.package(url: "https://github.com/naviapps/pointer-gesture-kit.git", from: "0.2.0") ``` Then add the library product you need to your target: @@ -52,6 +53,20 @@ Then add the library product you need to your target: Use `PointerGestureKit` for the recognizer core and platform-neutral contracts. Add `PointerGestureKitCoreGraphics` only when the target needs the live macOS Core Graphics event tap. +Convert between Core Graphics points and neutral gesture points at the adapter boundary: + +```swift +import CoreGraphics +import PointerGestureKit +import PointerGestureKitCoreGraphics + +let gesturePoint = GesturePoint(CGPoint(x: 10, y: 20)) +let cgPoint = gesturePoint.cgPoint +``` + +The Core Graphics bridge preserves finite coordinate values without flipping axes; platform-specific +screen or view coordinate transforms belong in the host app. + ## Documentation - [PointerGestureKit API reference](https://swiftpackageindex.com/naviapps/pointer-gesture-kit/documentation/pointergesturekit) @@ -83,82 +98,129 @@ func run(_: AppCommand) { ``` Pattern matching is exact: the completed gesture direction sequence must match a registered pattern. -Use `GesturePatternCatalog` when a host-owned settings UI needs to validate configured patterns -before building a matcher; it reports empty patterns and duplicate patterns, but shared prefixes are -valid because matching is exact. The catalog validates only pattern shape; host apps still own -command names, command conflicts, persistence, and migration policy. -`GesturePatternMatcher.register(pattern:match:)` returns `false` for empty patterns and otherwise -stores or replaces the exact pattern match. +`GesturePatternMatcher.register(pattern:match:)` returns `false` for an empty pattern. Otherwise it +stores the exact match and returns `true`; registering the same pattern again intentionally replaces +the previous match. Host apps own duplicate-rule policy, command conflicts, persistence, and settings +lifecycle. Provide app-specific callbacks, policy, and optional tuning through `GestureRecognizerConfiguration`: ```swift +import CoreGraphics import PointerGestureKit import PointerGestureKitCoreGraphics @MainActor -let eventTap = GestureEventTap() +func makeRecognizer() -> GestureRecognizer { + let eventTap = GestureEventTap() + let initialLocation = GesturePoint(CGPoint(x: 0, y: 0)) + _ = initialLocation.cgPoint + + let configuration = GestureRecognizerConfiguration( + makeMatcher: { _ in makeGestureMatcher() }, + onReplayRequested: { request in eventTap.replay(request) }, + onMatch: { command in + run(command) + }, + areModifiersSatisfied: { modifiers, _ in + modifiers.contains(.command) + } + ) -@MainActor -let configuration = GestureRecognizerConfiguration( - makeMatcher: { _ in makeGestureMatcher() }, - onReplayRequested: eventTap.replay, - onMatch: { command in - run(command) - }, - areModifiersSatisfied: { modifiers, _ in - modifiers.contains(.command) - } -) + return GestureRecognizer( + eventSource: eventTap, + configuration: configuration + ) +} ``` +`makeMatcher` returns the exact-pattern matcher for the active recognition context. Recording mode +records directions without requesting a matcher. `onReplayRequested` emits replay requests for consumed pointer-button input. Plain clicks request a full click for the configured recognition button. Unmatched or recording-mode drags request the consumed drag sequence so primary-button drawing and other drag workflows can be restored. Matched gestures request only the consumed button release after the host handles the command. Stopping or cancelling while consumed pointer-button input is active also requests the replay needed to leave host input state consistent. +`GestureEventTap.replay(_:)` maps click requests to button-down and button-up events, drag +requests to down/move/up events, and release requests to button-up events. Empty drag requests are +ignored by the Core Graphics adapter, and single-point drag requests replay as a click at that point. The default recognition button is `.secondary`. For a non-default recognition button, configure both the recognizer and the Core Graphics adapter with the same button: ```swift -let eventTap = GestureEventTap(capturedButtons: [.primary]) - -let configuration = GestureRecognizerConfiguration( - makeMatcher: { _ in makeGestureMatcher() }, - onReplayRequested: eventTap.replay, - onMatch: { command in - run(command) - }, - recognitionButton: .primary -) +import PointerGestureKit +import PointerGestureKitCoreGraphics + +@MainActor +func makePrimaryButtonRecognizer() -> GestureRecognizer { + let eventTap = GestureEventTap(capturedButtons: [.primary]) + + let configuration = GestureRecognizerConfiguration( + makeMatcher: { _ in makeGestureMatcher() }, + onReplayRequested: { request in eventTap.replay(request) }, + onMatch: { command in + run(command) + }, + recognitionButton: .primary + ) + + return GestureRecognizer( + eventSource: eventTap, + configuration: configuration + ) +} ``` +Use `PointerButton(auxiliaryButtonID:)` only for extra pointer buttons; identifiers `0`, `1`, and +`2` are reserved for `.primary`, `.secondary`, and `.middle`. + Passing an empty `capturedButtons` set to `GestureEventTap` captures no input and causes `start(handler:)` to return `false`; use the default initializer when secondary-button capture is intended. -Create and start the recognizer: +Store the recognizer for as long as recognition should stay active, then start it: ```swift +import PointerGestureKit + @MainActor -let recognizer = GestureRecognizer( - eventSource: eventTap, - configuration: configuration -) +final class GestureController { + private var recognizer: GestureRecognizer? -recognizer.start() + func startRecognition() { + let recognizer = makeRecognizer() + self.recognizer = recognizer + recognizer.start() + } +} +``` + +Stop and release the recognizer when the host no longer needs recognition: + +```swift +import PointerGestureKit + +@MainActor +extension GestureController { + func stopRecognition() { + recognizer?.stop() + recognizer = nil + } +} ``` The recognizer is `@MainActor` isolated. Drive it from main-actor UI/application code. `GestureEventTap.start(handler:)` returns `false` when no pointer buttons are configured or when the tap cannot be created, commonly because the host app has not been granted Accessibility permission. -PointerGestureKit reports that through recognizer lifecycle/failure state: `.failed` means no retry -is scheduled, while `.retrying` means the configured event-source start retry schedule is active. -`status.lastFailure` exposes startup, policy, modifier, and session-expiration failures. The host -app owns permission presentation, copy, and onboarding UI. +PointerGestureKit reports that through recognizer lifecycle/failure state: +`GestureRecognizerState.Status.Lifecycle.failed` means no retry is scheduled, while +`GestureRecognizerState.Status.Lifecycle.retrying` means the configured event-source start retry +schedule is active. +`status.lastFailure` exposes startup, enablement-policy, modifier-policy, and input-expiration +failures. The host app owns permission presentation, copy, and onboarding UI. Omit `recognitionContext` when gestures do not depend on app, window, or surface identity. Provide it when matching or enablement policy changes by host context. If only the current frontmost @@ -166,23 +228,33 @@ app matters, ignore the point argument. Return `nil` when no host context is act `GestureRecognitionContext(identifier:)` trims surrounding whitespace and returns `nil` for blank identifiers. Use `isRecognitionEnabled` when a valid context exists but should not currently accept gestures. +`GestureModifierFlags(rawValue:)` uses package-owned modifier bits, not platform modifier masks, and +discards bits that are not command, option, control, or shift. Set `isRecordingModeEnabled` only while teaching or recording a gesture pattern. Recording mode -captures directions without requiring modifier approval or a registered match, but it still respects -`isRecognitionEnabled` for the active context. Normal recognition mode also uses +captures directions without requiring modifier approval or a matcher, but it still respects +`isRecognitionEnabled` for the active context. Normal recognition mode uses `areModifiersSatisfied` and the configured matcher. A completed recording-mode drag still emits a -consumed drag-sequence replay request for the configured recognition button. +consumed drag-sequence replay request for the configured recognition button. Changes to +`isRecordingModeEnabled` apply to gesture sessions that start after the value changes; an active +session keeps the mode it started with. -Use the default `GestureRecognizerTuning` unless the host app needs different movement thresholds, -session expiration, raw trace retention, or event-source start retry delays. +Use `GestureRecognizerTuning.standard` unless the host app needs different movement thresholds, +gesture input expiration, raw trace retention, or event-source start retry delays. Create custom +values with `try GestureRecognizerTuning.validated(...)`. Invalid values produce a typed validation +error instead of silently changing recognition or retry behavior. ## Lifecycle Use `start()` to request event-source startup and `stop()` to stop recognition, cancel pending -startup retries, clear active gesture state, and reset the last failure. Use `retryStartNow()` only -after a previous `start()` request when the host wants to retry event-source startup immediately. -Use `cancelActiveGesture()` for user-driven cancellation of an in-progress gesture; when no gesture -session or pending button input exists, it is a no-op and preserves the current failure state. +startup retries, clear active gesture state, and reset the last failure. Calling `start()` again +after a failed or retrying start request retries startup immediately. Use `retryStartNow()` only +after a previous `start()` request when the host wants an explicit retry command separate from the +normal start action. +Use `cancelActiveGesture()` for user-driven cancellation of an in-progress gesture; when consumed +pointer-button input is active, it requests the replay needed to leave host input state consistent. +When no gesture session or pending button input exists, it is a no-op and preserves the current +failure state. ## Observation @@ -190,25 +262,35 @@ Use `observe`, `observeStatus`, or `observeTrace` to update UI without reaching internals: ```swift +import PointerGestureKit + +@MainActor +func observeTrace(from recognizer: GestureRecognizer) -> GestureObservationToken { + recognizer.observeTrace { trace in + renderTrace( + isVisible: trace.isVisible, + points: trace.rawPoints, + directions: trace.directions, + directionEndpoints: trace.directionEndpoints, + tailPoint: trace.tailPoint + ) + } +} + @MainActor func renderTrace( + isVisible: Bool, points: [GesturePoint], directions: [GestureDirection], - directionEndpoints: [GesturePoint] + directionEndpoints: [GesturePoint], + tailPoint: GesturePoint? ) { -} - -let token = recognizer.observeTrace { state in - renderTrace( - points: state.rawPoints, - directions: state.directions, - directionEndpoints: state.directionEndpoints - ) + guard isVisible else { return } } ``` Keep the returned `GestureObservationToken` for as long as the observation should remain active. -Cancelling or releasing the token removes the observer. +Cancelling or releasing the main-actor-isolated token removes the observer. `observeStatus` and `observeTrace` emit their initial value immediately, then emit only when that portion of the snapshot changes. Use `status.lifecycle` for event-source startup UI. Use `status.isCapturingGesture` for active @@ -216,9 +298,27 @@ gesture capture state and `status.isRecordingModeEnabled` for the host-controlle teaching/recording mode. Use `status.lastRecordedDirections` to read the most recently completed direction sequence, including recording-mode captures. `trace.directionEndpoints` contains the trace start point plus one normalized endpoint per direction for rendering gesture paths. +Use `trace.tailPoint` to extend a visible path to the latest pointer point without waiting for +another raw-point batch or direction change. +During normal recognition, `trace.isVisible` becomes true when the current direction sequence is a +valid matcher prefix. Completed matches are still exact; prefix visibility only controls feedback +while the gesture is in progress. Recording mode still publishes visible trace feedback while +directions are being captured. +`GestureRecognizerState`, `GestureRecognizerState.Status`, and `GestureRecognizerState.Trace` are +constructible so host-owned recognizer protocols and test doubles can use the same snapshot values +without `@testable` imports. +Lifecycle and failure values are finite enums intended for host UI state mapping, not persistence +formats. ## Responsibility Boundary +PointerGestureKit owns platform-neutral recognition values, matching, validation, and recognizer +state. PointerGestureKitCoreGraphics owns the Core Graphics event-tap and replay adapter. +Event-source implementations deliver normalized `GestureInputEvent` values through +`GestureEventSource.start(handler:)` and apply the returned `GestureEventDisposition` to the +original platform event: `.consume` suppresses it, while `.passThrough` leaves it in the platform +event stream. + PointerGestureKit intentionally does not own: - command catalogs, shortcuts, or action execution @@ -233,16 +333,17 @@ Those concerns should stay outside this package. ## Development -Local development commands use `make`. - -Run all local checks: +Run the package check with: ```sh make check ``` -For focused local work, run `make format`, `make lint`, `make test`, `make build`, or `make docc`. -GitHub Actions runs `make check` on pull requests and pushes to `main`. +GitHub Actions runs the same check on pull requests and pushes to `main`. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). Release notes are in [CHANGELOG.md](CHANGELOG.md). ## Security diff --git a/SECURITY.md b/SECURITY.md index 3d9061c..71c9828 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,20 +2,21 @@ ## Supported Versions -Before the first public release, security fixes target the default branch. After a public release, -security fixes target the latest released version. +Security updates are provided for the latest released version. ## Reporting a Vulnerability -Report security issues through GitHub private vulnerability reporting for this repository. +Report security issues through GitHub private vulnerability reporting for this +repository. -Do not open a public issue, pull request, or discussion for vulnerabilities, suspected credential -exposure, privacy-sensitive behavior, or issues that could expose private user data. +Do not open a public issue, pull request, or discussion for vulnerabilities, +suspected credential exposure, privacy-sensitive behavior, or issues that could +expose private user data. -If private vulnerability reporting is unavailable, open a public issue asking for a private -security contact channel. Do not include vulnerability details, exploit steps, logs, secrets, -tokens, private keys, personal data, local paths, private app metadata, or app-specific internal -references. +If private vulnerability reporting is unavailable, open a public issue asking +for a private security contact channel. Do not include vulnerability details, +exploit steps, logs, secrets, tokens, private keys, personal data, local paths, +private app metadata, or app-specific internal references. For private reports, include: @@ -26,13 +27,11 @@ For private reports, include: - Expected impact - Affected public API, target, or subsystem -Use placeholders instead of secrets, tokens, private keys, personal data, local paths, private app -metadata, or app-specific internal references. +Use placeholders instead of secrets, tokens, private keys, personal data, local +paths, private app metadata, or app-specific internal references. -We will acknowledge valid reports as soon as practical and coordinate fixes before public -disclosure. - -For non-security bugs, use the public bug report template. +We will acknowledge valid reports as soon as practical and coordinate fixes +before public disclosure. ## Scope @@ -42,8 +41,9 @@ Security-sensitive areas include: - CGEvent tap creation, disabling, and re-enabling - Pointer-button, drag, and key-event disposition - Suppression of synthetic events when replaying host-app behavior -- Private user activity visible to host-app event handlers or overlays +- Private user activity visible to host-app event handlers, diagnostics, or UI + consuming recognizer state -PointerGestureKit does not collect, transmit, persist, or log pointer events by itself. Host -applications are responsible for Accessibility onboarding, storage, telemetry, logging, UI, and -privacy policies. +PointerGestureKit does not collect, transmit, persist, or log pointer events by +itself. Host applications are responsible for Accessibility onboarding, +storage, telemetry, logging, UI, and privacy policies. diff --git a/Sources/PointerGestureKit/Events/GestureEventDisposition.swift b/Sources/PointerGestureKit/Events/GestureEventDisposition.swift index 4e073f6..01efea4 100644 --- a/Sources/PointerGestureKit/Events/GestureEventDisposition.swift +++ b/Sources/PointerGestureKit/Events/GestureEventDisposition.swift @@ -1,7 +1,19 @@ -/// Indicates how an event source should handle the original platform event. -public enum GestureEventDisposition: Hashable, Sendable { +/// The action an event source applies to the original platform event after delivering normalized +/// gesture input. +public enum GestureEventDisposition: Equatable, Sendable { /// Suppresses the original platform event after the normalized event is handled. case consume + /// Leaves the original platform event in the platform event stream. case passThrough + + /// Whether the original platform event should be suppressed after the normalized event is handled. + public var consumesOriginalEvent: Bool { + switch self { + case .consume: + true + case .passThrough: + false + } + } } diff --git a/Sources/PointerGestureKit/Events/GestureEventSource.swift b/Sources/PointerGestureKit/Events/GestureEventSource.swift index 239a8c5..6f0a186 100644 --- a/Sources/PointerGestureKit/Events/GestureEventSource.swift +++ b/Sources/PointerGestureKit/Events/GestureEventSource.swift @@ -1,6 +1,9 @@ /// Supplies normalized input events from a live platform source. +/// +/// Event sources are main-actor reference types because capture lifecycle and delivery are tied to +/// the UI event loop. @MainActor -public protocol GestureEventSource: AnyObject, Sendable { +public protocol GestureEventSource: AnyObject { /// Starts the event source with the supplied event handler. /// /// Implementations should pass normalized input events to the handler and apply the returned diff --git a/Sources/PointerGestureKit/Events/GestureInputEvent.swift b/Sources/PointerGestureKit/Events/GestureInputEvent.swift index 8ab7e69..5153f50 100644 --- a/Sources/PointerGestureKit/Events/GestureInputEvent.swift +++ b/Sources/PointerGestureKit/Events/GestureInputEvent.swift @@ -1,11 +1,11 @@ /// A platform-neutral pointer or semantic cancel event used by the recognizer. -public struct GestureInputEvent: Hashable, Sendable { +public struct GestureInputEvent: Equatable, Sendable { /// The normalized event kind. - public enum Kind: Hashable, Sendable { + public enum Kind: Equatable, Sendable { /// A pointer button was pressed. case buttonDown(PointerButton) /// A pointer button moved while pressed. - case buttonDragged(PointerButton) + case buttonMoved(PointerButton) /// A pointer button was released. case buttonUp(PointerButton) /// A semantic cancel event occurred. diff --git a/Sources/PointerGestureKit/Events/GestureModifierFlags.swift b/Sources/PointerGestureKit/Events/GestureModifierFlags.swift index d2413d8..65ff10f 100644 --- a/Sources/PointerGestureKit/Events/GestureModifierFlags.swift +++ b/Sources/PointerGestureKit/Events/GestureModifierFlags.swift @@ -1,26 +1,26 @@ -/// Known platform-neutral modifier keys captured with a gesture event. +/// Package-owned modifier flags captured with a gesture event. +/// +/// Known flags use package-owned bit positions, not platform modifier-mask values. public struct GestureModifierFlags: OptionSet, Hashable, Sendable { - /// The raw option-set value with unknown modifier bits removed. + /// The raw option-set value. public let rawValue: Int - /// Creates modifier flags from a raw option-set value, discarding unknown bits. + /// Creates modifier flags from a raw option-set value. public init(rawValue: Int) { - self.rawValue = rawValue & Self.knownRawMask + self.rawValue = rawValue } - /// The Command key. + /// The Command modifier captured with a gesture event. public static let command = GestureModifierFlags(rawValue: commandRawValue) - /// The Option key. + /// The Option modifier captured with a gesture event. public static let option = GestureModifierFlags(rawValue: optionRawValue) - /// The Control key. + /// The Control modifier captured with a gesture event. public static let control = GestureModifierFlags(rawValue: controlRawValue) - /// The Shift key. + /// The Shift modifier captured with a gesture event. public static let shift = GestureModifierFlags(rawValue: shiftRawValue) private static let commandRawValue = 1 << 0 private static let optionRawValue = 1 << 1 private static let controlRawValue = 1 << 2 private static let shiftRawValue = 1 << 3 - private static let knownRawMask = - commandRawValue | optionRawValue | controlRawValue | shiftRawValue } diff --git a/Sources/PointerGestureKit/Events/GestureReplayRequest.swift b/Sources/PointerGestureKit/Events/GestureReplayRequest.swift index 16ca689..bc419b3 100644 --- a/Sources/PointerGestureKit/Events/GestureReplayRequest.swift +++ b/Sources/PointerGestureKit/Events/GestureReplayRequest.swift @@ -1,39 +1,17 @@ /// A platform-neutral request to replay consumed pointer-button input. -public enum GestureReplayRequest: Hashable, Sendable { +public enum GestureReplayRequest: Equatable, Sendable { /// A request to replay a full pointer-button click at the provided location. case click(button: PointerButton, at: GesturePoint) - /// A request to replay a consumed pointer-button down, drag, and release sequence. + /// A request to replay consumed pointer-button locations as a drag sequence. + /// + /// Platform adapters decide how to map the ordered locations, including empty and single-point + /// requests, to concrete events. case drag(button: PointerButton, points: [GesturePoint]) + /// A request to replay the beginning of a consumed pointer-button drag without a release. + /// + /// Use this when recognition stops before the physical button is released. Platform adapters + /// should replay the button-down and consumed drag movement, then allow later real events through. + case dragStart(button: PointerButton, points: [GesturePoint]) /// A request to replay only the pointer-button release at the provided location. case release(button: PointerButton, at: GesturePoint) - - static func consumedButtonInput( - button: PointerButton, - points: [GesturePoint], - clickAt point: GesturePoint - ) -> Self { - points.count > 1 - ? .drag(button: button, points: points) - : .click(button: button, at: point) - } - - /// The pointer button used by the replay request. - public var button: PointerButton { - switch self { - case let .click(button, _), let .drag(button, _), let .release(button, _): - button - } - } - - /// The final location affected by the replay request. - /// - /// Empty drag requests have no replay location. - public var location: GesturePoint? { - switch self { - case let .click(_, location), let .release(_, location): - location - case let .drag(_, points): - points.last - } - } } diff --git a/Sources/PointerGestureKit/Events/PointerButton.swift b/Sources/PointerGestureKit/Events/PointerButton.swift index fe92b6a..cf158da 100644 --- a/Sources/PointerGestureKit/Events/PointerButton.swift +++ b/Sources/PointerGestureKit/Events/PointerButton.swift @@ -4,33 +4,33 @@ public struct PointerButton: Hashable, Sendable { case primary case secondary case middle - case additional(UInt32) + case auxiliary(UInt32) } - /// The primary pointer button, commonly the left mouse button. + /// The primary pointer button. public static let primary = Self(storage: .primary) - /// The secondary pointer button, commonly the right mouse button. + /// The secondary pointer button. public static let secondary = Self(storage: .secondary) /// The middle pointer button. public static let middle = Self(storage: .middle) private let storage: Storage - /// The button number for an additional pointer button. + /// The platform-neutral identifier for an auxiliary pointer button. /// /// Primary, secondary, and middle buttons return `nil` because they are modeled explicitly. - public var additionalButtonNumber: UInt32? { - guard case let .additional(buttonNumber) = storage else { return nil } - return buttonNumber + public var auxiliaryButtonID: UInt32? { + guard case let .auxiliary(buttonID) = storage else { return nil } + return buttonID } - /// Creates an additional pointer button. + /// Creates an auxiliary pointer button. /// - /// Returns `nil` for button numbers already modeled by ``primary``, ``secondary``, and + /// Returns `nil` for identifiers already modeled by ``primary``, ``secondary``, and /// ``middle``. - public init?(additionalButtonNumber: UInt32) { - guard additionalButtonNumber > 2 else { return nil } - storage = .additional(additionalButtonNumber) + public init?(auxiliaryButtonID: UInt32) { + guard auxiliaryButtonID > 2 else { return nil } + storage = .auxiliary(auxiliaryButtonID) } private init(storage: Storage) { diff --git a/Sources/PointerGestureKit/Geometry/GestureDirection.swift b/Sources/PointerGestureKit/Geometry/GestureDirection.swift index b6afaa6..fd5da0a 100644 --- a/Sources/PointerGestureKit/Geometry/GestureDirection.swift +++ b/Sources/PointerGestureKit/Geometry/GestureDirection.swift @@ -1,5 +1,5 @@ /// A cardinal direction in a pointer gesture pattern or trace. -public enum GestureDirection: Hashable, CaseIterable, Sendable { +public enum GestureDirection: Hashable, Sendable { /// Movement toward a decreasing y coordinate. case up /// Movement toward an increasing y coordinate. diff --git a/Sources/PointerGestureKit/Recognizer/GestureMovementResolver.swift b/Sources/PointerGestureKit/Geometry/GestureMovementResolver.swift similarity index 74% rename from Sources/PointerGestureKit/Recognizer/GestureMovementResolver.swift rename to Sources/PointerGestureKit/Geometry/GestureMovementResolver.swift index ee20e33..3392b89 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureMovementResolver.swift +++ b/Sources/PointerGestureKit/Geometry/GestureMovementResolver.swift @@ -1,11 +1,11 @@ enum GestureMovementResolver { /// Returns the larger absolute coordinate delta between two points. - static func maximumAxisDelta(from start: GesturePoint, to end: GesturePoint) -> Double { + static func maximumAbsoluteAxisDelta(from start: GesturePoint, to end: GesturePoint) -> Double { max(abs(end.x - start.x), abs(end.y - start.y)) } - /// Returns the dominant cardinal direction between two points. - static func dominantDirection( + /// Returns the cardinal direction on the dominant movement axis between two points. + static func dominantAxisDirection( from start: GesturePoint, to end: GesturePoint ) -> GestureDirection? { diff --git a/Sources/PointerGestureKit/Geometry/GesturePoint.swift b/Sources/PointerGestureKit/Geometry/GesturePoint.swift index 8b102bb..0f01aed 100644 --- a/Sources/PointerGestureKit/Geometry/GesturePoint.swift +++ b/Sources/PointerGestureKit/Geometry/GesturePoint.swift @@ -1,22 +1,23 @@ -/// A finite point in gesture coordinate space. +/// A point in gesture coordinate space. /// /// Gesture direction recognition treats positive vertical movement as downward. -public struct GesturePoint: Hashable, Sendable { +public struct GesturePoint: Equatable, Sendable { /// The horizontal coordinate. public let x: Double /// The vertical coordinate. public let y: Double - /// Creates a gesture point, replacing non-finite coordinates with zero. + /// Creates a gesture point. public init(x: Double, y: Double) { - self.x = Self.finite(x) - self.y = Self.finite(y) + self.x = x + self.y = y } /// The origin point. public static let zero = GesturePoint(x: 0, y: 0) - private static func finite(_ value: Double) -> Double { - value.isFinite ? value : 0 + /// Whether both coordinates are finite and safe for recognition or platform replay. + public var isFinite: Bool { + x.isFinite && y.isFinite } } diff --git a/Sources/PointerGestureKit/Matching/GesturePatternCatalog.swift b/Sources/PointerGestureKit/Matching/GesturePatternCatalog.swift deleted file mode 100644 index 7ee58db..0000000 --- a/Sources/PointerGestureKit/Matching/GesturePatternCatalog.swift +++ /dev/null @@ -1,80 +0,0 @@ -/// A host-defined collection of gesture direction patterns that can be checked before matching. -public struct GesturePatternCatalog: Hashable, Sendable { - /// A validation issue in a host-defined gesture pattern catalog. - public enum ValidationIssue: Hashable, Sendable { - /// A pattern is empty and cannot be matched. - case emptyPattern(index: Int) - /// A pattern duplicates an earlier pattern and would map the same gesture to multiple host intents. - case duplicatePattern( - pattern: [GestureDirection], - originalIndex: Int, - duplicateIndex: Int - ) - - /// The host-defined pattern index that should be treated as invalid. - public var patternIndex: Int { - switch self { - case let .emptyPattern(index): - index - case let .duplicatePattern(_, _, duplicateIndex): - duplicateIndex - } - } - - /// The earlier pattern index related to this issue, when one exists. - public var relatedPatternIndex: Int? { - switch self { - case .emptyPattern: - nil - case let .duplicatePattern(_, originalIndex, _): - originalIndex - } - } - } - - /// Direction patterns in host-defined order. - public let patterns: [[GestureDirection]] - - /// Creates a catalog from host-defined direction patterns. - public init(patterns: [[GestureDirection]]) { - self.patterns = patterns - } - - /// Returns empty or duplicate patterns in host-defined order. - public var validationIssues: [ValidationIssue] { - Self.validationIssues(for: patterns) - } - - /// Returns `true` when no validation issues are present. - public var isValid: Bool { - validationIssues.isEmpty - } - - /// Returns empty or duplicate patterns in host-defined order. - public static func validationIssues( - for patterns: [[GestureDirection]] - ) -> [ValidationIssue] { - var issues: [ValidationIssue] = [] - var originalIndexByPattern: [[GestureDirection]: Int] = [:] - - for (index, pattern) in patterns.enumerated() { - guard !pattern.isEmpty else { - issues.append(.emptyPattern(index: index)) - continue - } - - if let originalIndex = originalIndexByPattern[pattern] { - issues.append( - .duplicatePattern( - pattern: pattern, - originalIndex: originalIndex, - duplicateIndex: index - )) - } else { - originalIndexByPattern[pattern] = index - } - } - - return issues - } -} diff --git a/Sources/PointerGestureKit/Matching/GesturePatternMatcher.swift b/Sources/PointerGestureKit/Matching/GesturePatternMatcher.swift index 8980ba6..73d625f 100644 --- a/Sources/PointerGestureKit/Matching/GesturePatternMatcher.swift +++ b/Sources/PointerGestureKit/Matching/GesturePatternMatcher.swift @@ -4,16 +4,22 @@ /// ``GestureRecognizer``. Use ``match(pattern:)`` to verify configured patterns in host-app tests. public struct GesturePatternMatcher: Sendable { private var children: [GestureDirection: GesturePatternMatcher] = [:] - private(set) var currentMatch: Match? + private var terminalMatch: Match? /// Creates an empty matcher. public init() {} + var isEmpty: Bool { + children.isEmpty && terminalMatch == nil + } + /// Registers a direction pattern with an app-owned match value. /// - /// Registering the same pattern again replaces the previous match. + /// Registering the same pattern again replaces the previous match. Patterns that share prefixes + /// with other registered patterns remain distinct. /// - /// Returns `false` and leaves the matcher unchanged when the pattern is empty. + /// Returns `false` without changing the matcher when the pattern is empty. Otherwise returns + /// `true`; registering the same pattern again intentionally replaces its match. @discardableResult public mutating func register(pattern: [GestureDirection], match: Match) -> Bool { guard !pattern.isEmpty else { return false } @@ -35,7 +41,7 @@ public struct GesturePatternMatcher: Sendable { var child = children[first] ?? GesturePatternMatcher() let remaining = pattern.dropFirst() if remaining.isEmpty { - child.currentMatch = match + child.terminalMatch = match } else { child.insert(pattern: remaining, match: match) } @@ -46,12 +52,16 @@ public struct GesturePatternMatcher: Sendable { children[direction] } + func completedMatch() -> Match? { + terminalMatch + } + private func match(pattern: ArraySlice) -> Match? { guard let first = pattern.first, let child = children[first] else { return nil } let remaining = pattern.dropFirst() if remaining.isEmpty { - return child.currentMatch + return child.terminalMatch } return child.match(pattern: remaining) } diff --git a/Sources/PointerGestureKit/Observation/GestureObservationToken.swift b/Sources/PointerGestureKit/Observation/GestureObservationToken.swift new file mode 100644 index 0000000..588af46 --- /dev/null +++ b/Sources/PointerGestureKit/Observation/GestureObservationToken.swift @@ -0,0 +1,20 @@ +/// A cancellable token that keeps a recognizer observation active. +@MainActor +public final class GestureObservationToken { + private var onCancel: (@MainActor () -> Void)? + + init(onCancel: @escaping @MainActor () -> Void) { + self.onCancel = onCancel + } + + isolated deinit { + cancel() + } + + /// Cancels the observation if it is still active. + public func cancel() { + guard let onCancel else { return } + self.onCancel = nil + onCancel() + } +} diff --git a/Sources/PointerGestureKit/Observation/GestureRecognizer+Observation.swift b/Sources/PointerGestureKit/Observation/GestureRecognizer+Observation.swift new file mode 100644 index 0000000..65023b7 --- /dev/null +++ b/Sources/PointerGestureKit/Observation/GestureRecognizer+Observation.swift @@ -0,0 +1,161 @@ +extension GestureRecognizer { + /// Current recognizer state snapshot. + public var snapshot: GestureRecognizerState { + GestureRecognizerState(status: currentStatus, trace: currentTrace) + } + + private var currentStatus: GestureRecognizerState.Status { + GestureRecognizerState.Status( + lifecycle: currentLifecycle, + isCapturingGesture: isCapturingGesture, + isRecordingModeEnabled: isRecordingModeEnabled, + lastRecordedDirections: lastRecordedDirections, + lastFailure: lastFailure + ) + } + + private var currentLifecycle: GestureRecognizerState.Status.Lifecycle { + switch lifecycleState { + case .idle: .idle + case .starting: .starting + case .ready: .ready + case .retrying: .retrying + case .failed: .failed + } + } + + private var currentTrace: GestureRecognizerState.Trace { + GestureRecognizerState.Trace( + isVisible: traceIsVisible, + rawPoints: traceRawPoints, + directions: traceDirections, + directionEndpoints: traceDirectionEndpoints, + tailPoint: traceTailPoint + ) + } + + /// Observes combined recognizer state changes. + @discardableResult + public func observe( + _ handler: @escaping @MainActor @Sendable (GestureRecognizerState) -> Void + ) -> GestureObservationToken { + observationCenter.observeSnapshot(currentState: snapshot, handler: handler) + } + + /// Observes the status portion of the recognizer snapshot. + @discardableResult + public func observeStatus( + _ handler: @escaping @MainActor @Sendable (GestureRecognizerState.Status) -> Void + ) -> GestureObservationToken { + let observer = DistinctStateObserver() + return observe { state in + observer.deliver(state.status, to: handler) + } + } + + /// Observes the trace portion of the recognizer snapshot with immediate live-trace delivery. + @discardableResult + public func observeTrace( + _ handler: @escaping @MainActor @Sendable (GestureRecognizerState.Trace) -> Void + ) -> GestureObservationToken { + observationCenter.observeTrace(currentTrace: currentTrace, handler: handler) + } + + func scheduleSnapshotNotificationIfChanged( + from oldValue: Value, + to newValue: Value + ) { + guard !suppressSnapshotNotifications, oldValue != newValue else { return } + observationCenter.scheduleCurrentSnapshot(snapshot) + } + + func discardPendingVisibleTraceNotifications() { + observationCenter.discardPendingVisibleTraceSnapshots() + } + + func updatePublishedState( + from session: GestureSession, + publishRawPoints: Bool, + notifyTrace: Bool? = nil + ) { + let previousTrace = currentTrace + let wasVisible = traceIsVisible + let shouldDeliverInitialVisibleTrace = + !wasVisible && session.trace.isVisible + && shouldPublishInitialVisibleTraceImmediately(for: session) + let shouldDeliverTrace = notifyTrace ?? (publishRawPoints || traceRawPoints.isEmpty) + let publishedRawPoints = publishRawPoints ? session.trace.rawPoints : traceRawPoints + + suppressSnapshotNotifications = !shouldDeliverTrace + traceRawPoints = publishedRawPoints + traceDirectionEndpoints = session.trace.directionEndpoints + traceDirections = session.trace.directions + traceTailPoint = session.trace.isVisible ? session.trace.tailPoint : nil + suppressSnapshotNotifications = false + + traceIsVisible = session.trace.isVisible + isCapturingGesture = session.recognition.isCapturingGesture + + if !wasVisible && traceIsVisible { + observationCenter.enqueueSnapshot(snapshot) + } + deliverTraceIfNeeded( + from: previousTrace, + to: currentTrace, + shouldDeliverTrace: shouldDeliverTrace, + shouldDeliverInitialVisibleTrace: shouldDeliverInitialVisibleTrace + ) + } + + func clearPublishedState(discardPendingVisibleTraceNotifications: Bool = false) { + let previousTrace = currentTrace + let wasVisible = traceIsVisible + if discardPendingVisibleTraceNotifications { + observationCenter.discardPendingVisibleTraceSnapshots() + } + + traceRawPoints = [] + traceDirections = [] + traceDirectionEndpoints = [] + traceTailPoint = nil + traceIsVisible = false + isCapturingGesture = false + + if wasVisible { + observationCenter.enqueueSnapshot(snapshot) + } + deliverTraceIfNeeded( + from: previousTrace, + to: currentTrace, + shouldDeliverTrace: true, + shouldDeliverInitialVisibleTrace: false + ) + } + + private func deliverTraceIfNeeded( + from previousTrace: GestureRecognizerState.Trace, + to nextTrace: GestureRecognizerState.Trace, + shouldDeliverTrace: Bool, + shouldDeliverInitialVisibleTrace: Bool + ) { + guard previousTrace != nextTrace else { return } + guard shouldDeliverTrace || previousTrace.isVisible != nextTrace.isVisible else { return } + + if shouldDeliverInitialVisibleTrace { + observationCenter.deliverTrace(nextTrace) { _ in true } + } else if previousTrace.isVisible { + observationCenter.deliverTrace(nextTrace) { $0.hasDeliveredVisibleTrace } + } + } +} + +@MainActor +private final class DistinctStateObserver { + private var lastValue: Value? + + func deliver(_ value: Value, to handler: @MainActor @Sendable (Value) -> Void) { + guard lastValue != value else { return } + lastValue = value + handler(value) + } +} diff --git a/Sources/PointerGestureKit/Observation/GestureRecognizerObservationCenter.swift b/Sources/PointerGestureKit/Observation/GestureRecognizerObservationCenter.swift new file mode 100644 index 0000000..a3f4b47 --- /dev/null +++ b/Sources/PointerGestureKit/Observation/GestureRecognizerObservationCenter.swift @@ -0,0 +1,130 @@ +import Foundation + +@MainActor +final class GestureRecognizerObservationCenter { + private var snapshotObservers: [UUID: @MainActor @Sendable (GestureRecognizerState) -> Void] = [:] + private var traceObservers: [UUID: GestureTraceObserver] = [:] + private var snapshotNotificationTask: Task? + private var pendingSnapshotNotifications: [SnapshotNotification] = [] + private var pendingCurrentSnapshot: SnapshotNotification? + + func observeSnapshot( + currentState: GestureRecognizerState, + handler: @escaping @MainActor @Sendable (GestureRecognizerState) -> Void + ) -> GestureObservationToken { + let id = UUID() + snapshotObservers[id] = handler + handler(currentState) + return GestureObservationToken { [weak self] in + self?.snapshotObservers.removeValue(forKey: id) + } + } + + func observeTrace( + currentTrace: GestureRecognizerState.Trace, + handler: @escaping @MainActor @Sendable (GestureRecognizerState.Trace) -> Void + ) -> GestureObservationToken { + let id = UUID() + let observer = GestureTraceObserver(handler: handler) + observer.deliver(currentTrace) + traceObservers[id] = observer + return GestureObservationToken { [weak self] in + self?.traceObservers.removeValue(forKey: id) + } + } + + func scheduleCurrentSnapshot(_ state: GestureRecognizerState) { + let observerIDs = Set(snapshotObservers.keys) + guard !observerIDs.isEmpty else { return } + pendingCurrentSnapshot = SnapshotNotification(state: state, observerIDs: observerIDs) + scheduleFlushIfNeeded() + } + + func enqueueSnapshot(_ state: GestureRecognizerState) { + guard !snapshotObservers.isEmpty else { return } + pendingSnapshotNotifications.append( + SnapshotNotification(state: state, observerIDs: Set(snapshotObservers.keys)) + ) + scheduleFlushIfNeeded() + } + + func discardPendingVisibleTraceSnapshots() { + pendingSnapshotNotifications.removeAll { $0.state.trace.isVisible } + } + + func deliverTrace( + _ trace: GestureRecognizerState.Trace, + where shouldDeliver: (GestureTraceObserver) -> Bool + ) { + for observer in Array(traceObservers.values) where shouldDeliver(observer) { + observer.deliver(trace) + } + } + + private func scheduleFlushIfNeeded() { + guard snapshotNotificationTask == nil else { return } + snapshotNotificationTask = Task { @MainActor [weak self] in + guard !Task.isCancelled else { return } + self?.flush() + } + } + + private func flush() { + snapshotNotificationTask = nil + var notifications = pendingSnapshotNotifications + pendingSnapshotNotifications.removeAll() + if let pendingCurrentSnapshot, + notifications.last?.state != pendingCurrentSnapshot.state + { + notifications.append(pendingCurrentSnapshot) + } + self.pendingCurrentSnapshot = nil + + for notification in coalesced(notifications) { + for observer in notification.observerIDs.compactMap({ snapshotObservers[$0] }) { + observer(notification.state) + } + } + } + + private func coalesced(_ notifications: [SnapshotNotification]) -> [SnapshotNotification] { + var result: [SnapshotNotification] = [] + for notification in notifications { + if notification.state.trace.isVisible, + let previous = result.last, + previous.state.trace.isVisible, + previous.observerIDs == notification.observerIDs + { + result[result.index(before: result.endIndex)] = notification + } else { + result.append(notification) + } + } + return result + } +} + +private struct SnapshotNotification { + let state: GestureRecognizerState + let observerIDs: Set +} + +@MainActor +final class GestureTraceObserver { + private var lastTrace: GestureRecognizerState.Trace? + private let handler: @MainActor @Sendable (GestureRecognizerState.Trace) -> Void + + init(handler: @escaping @MainActor @Sendable (GestureRecognizerState.Trace) -> Void) { + self.handler = handler + } + + var hasDeliveredVisibleTrace: Bool { + lastTrace?.isVisible == true + } + + func deliver(_ trace: GestureRecognizerState.Trace) { + guard lastTrace != trace else { return } + lastTrace = trace + handler(trace) + } +} diff --git a/Sources/PointerGestureKit/PointerGestureKit.docc/PointerGestureKit.md b/Sources/PointerGestureKit/PointerGestureKit.docc/PointerGestureKit.md index ef26ffd..2ee5652 100644 --- a/Sources/PointerGestureKit/PointerGestureKit.docc/PointerGestureKit.md +++ b/Sources/PointerGestureKit/PointerGestureKit.docc/PointerGestureKit.md @@ -7,24 +7,26 @@ Recognize pointer gestures from platform-neutral input events. PointerGestureKit contains the recognizer core, direction model, matcher, recognition policy inputs, observable state, and event-source protocol used to turn pointer-button drag paths into app-owned matches. Recognition defaults to -the secondary button and can be configured for primary, middle, or additional +the secondary button and can be configured for primary, middle, or auxiliary pointer buttons. Gesture movement is recorded as one of four cardinal directions: up, down, left, and right. Movement without a dominant axis does not add a direction. -`GesturePoint` values use gesture coordinates where positive vertical movement -resolves as down. +`GesturePoint` values use gesture coordinates where positive vertical movement resolves as down. +The recognizer passes through input with non-finite coordinates. The core target does not depend on AppKit, Core Graphics, or live input capture. Host apps provide event sources to ``GestureRecognizer`` and provide recognition-context policy plus callbacks through ``GestureRecognizerConfiguration``. Trace rendering, Accessibility permission presentation, persistence, telemetry, and command execution stay outside this target. +Use ``GestureInputEvent/Kind`` to distinguish pointer-button down, move, and up input events. Use ``GesturePatternMatcher`` to map direction patterns to app-owned match values, then create a ``GestureRecognizer`` with an event source and configuration. ## Usage -Register app-owned matches with ``GesturePatternMatcher``: +Register app-owned matches with ``GesturePatternMatcher`` and pass the matcher to a +``GestureRecognizer`` configuration: ```swift import PointerGestureKit @@ -34,80 +36,24 @@ enum AppCommand: Sendable { case focusSearch } -var matcher = GesturePatternMatcher() -matcher.register(pattern: [.down, .right], match: .showInspector) -matcher.register(pattern: [.up, .left], match: .focusSearch) +@MainActor +func makeGestureMatcher() -> GesturePatternMatcher { + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.down, .right], match: .showInspector) + matcher.register(pattern: [.up, .left], match: .focusSearch) + return matcher +} ``` -Pattern matching is exact: the completed gesture direction sequence must match a registered pattern. -Use ``GesturePatternCatalog`` when a host-owned settings UI needs to validate configured patterns -before building a matcher. It reports empty patterns and duplicate patterns, but shared prefixes are -valid because matching is exact. Use ``GesturePatternCatalog/ValidationIssue/patternIndex`` to -highlight the invalid host-defined pattern row and -``GesturePatternCatalog/ValidationIssue/relatedPatternIndex`` to reference the original row for -duplicates. The catalog validates only pattern shape; host apps still own command names, command -conflicts, persistence, and migration policy. -``GesturePatternMatcher/register(pattern:match:)`` returns `false` for empty patterns and otherwise -stores or replaces the exact pattern match. - -Configure recognition callbacks, policy, and tuning through -``GestureRecognizerConfiguration``. The recognizer keeps gesture-session state -and emits app-owned matches; the host app decides how to execute commands. -Use the `onReplayRequested` initializer argument to handle ``GestureReplayRequest`` -values after the recognizer consumes pointer-button input. Plain clicks request -a full click for the configured recognition button. Unmatched or recording-mode -drags request the consumed drag sequence so drag workflows can be restored; -a recognized match requests only the consumed button release. Replay requests expose their -configured ``GestureReplayRequest/button`` and final ``GestureReplayRequest/location`` for -host-side routing or diagnostics. Stopping or cancelling while consumed pointer-button input is -active also emits the replay needed to leave host input state consistent. Use the `onMatch` -initializer argument to receive the configured app-owned match value. - -Recognition uses the secondary button by default. Set the `recognitionButton` -initializer argument when gestures should start from the primary, middle, or an -additional pointer button, and provide an event source that emits the same -button. - -Use the `recognitionContext` initializer argument only when matching or policy -depends on the app, window, or surface under the gesture start point. Apps that -only need a current/frontmost context can ignore the point argument. Return -`nil` when no host context is active. Use `isRecognitionEnabled` to disable -recognition for a context without encoding that decision into the context -identifier. ``GestureRecognitionContext/init(identifier:)`` trims surrounding whitespace and -returns `nil` for blank identifiers, matching the no-context path. - -Use ``GestureRecognizer/isRecordingModeEnabled`` only for teaching or recording -gesture patterns. Recording mode captures directions without requiring modifier -approval or a registered match, but it still respects the `isRecognitionEnabled` -initializer argument for the active context. Normal recognition mode also uses -the `areModifiersSatisfied` initializer argument plus the configured matcher. -A completed recording-mode drag still emits a consumed drag-sequence replay request -for the configured recognition button. Read -``GestureRecognizerState/Status/lastRecordedDirections`` from status observation when the host needs -the most recently completed sequence. - -Use ``GestureRecognizer/start()`` to request event-source startup and -``GestureRecognizer/stop()`` to stop recognition, cancel pending startup retries, -clear active gesture state, and reset the last failure. Use -``GestureRecognizer/retryStartNow()`` only after a previous `start()` request -when the host wants to retry event-source startup immediately. Use -``GestureRecognizer/cancelActiveGesture()`` for user-driven cancellation of an -in-progress gesture; when no gesture session or pending button input exists, it -is a no-op and preserves the current failure state. - -Use ``GestureRecognizer/observe(_:)`` for combined snapshots. Use -``GestureRecognizer/observeStatus(_:)`` or ``GestureRecognizer/observeTrace(_:)`` -when a UI only needs one portion of the snapshot; those observers emit their -initial value immediately and then only emit when that portion changes. Use -``GestureRecognizerState/Status/lifecycle`` for event-source startup UI. A `.failed` -lifecycle means event-source startup failed with no scheduled retry; `.retrying` -means the configured retry schedule is active. Use -``GestureRecognizerState/Status/isCapturingGesture`` for active gesture capture state -and ``GestureRecognizerState/Status/isRecordingModeEnabled`` for the host-controlled -teaching/recording mode. Use ``GestureRecognizerState/Status/lastRecordedDirections`` for the most -recently completed sequence and ``GestureRecognizerState/Status/lastFailure`` for startup, policy, -modifier, and session-expiration failures. ``GestureRecognizerState/Trace/directionEndpoints`` -contains the trace start point plus one normalized endpoint per direction for trace rendering. +Pattern matching is exact. During normal recognition, trace visibility begins when the current +direction sequence is a valid matcher prefix; completed matches still require an exact registered +pattern. Host apps validate settings-level conflicts before building a matcher. +Use ``GestureRecognizerConfiguration`` to provide policy callbacks, replay handling, match handling, +and tuning. Keep the returned ``GestureObservationToken`` while status or trace observation should +remain active. +Use ``GestureRecognizer/observeTrace(_:)`` for live overlay feedback; after a trace becomes visible, +tail-point and hidden-state updates are delivered during input handling so hosts can render fast +gestures without an extra main-actor turn. ## Recognition Pipeline @@ -123,7 +69,11 @@ The recognizer turns platform-neutral input into a match through a small fixed p PointerGestureKit owns direction recognition, matching, recognizer state, trace observation, event-disposition modeling, event-source start retry behavior, and platform-neutral event-source -contracts. +contracts. Event-source implementations expose ``GestureEventSource/start(handler:)`` and +``GestureEventSource/stop()`` as the only live-input lifecycle hooks consumed by the recognizer. +They apply the returned ``GestureEventDisposition`` to the original platform event: +``GestureEventDisposition/consume`` suppresses it, while +``GestureEventDisposition/passThrough`` leaves it in the platform event stream. PointerGestureKit does not own command catalogs, command execution, overlay UI, Accessibility permission presentation, privacy disclosures, persistence, telemetry, analytics, non-macOS @@ -134,38 +84,123 @@ event-source adapters, or diagonal gesture directions. ### Recognizer - ``GestureRecognizer`` +- ``GestureRecognizer/init(eventSource:configuration:)`` +- ``GestureRecognizer/isRecordingModeEnabled`` +- ``GestureRecognizer/start()`` +- ``GestureRecognizer/stop()`` +- ``GestureRecognizer/retryStartNow()`` +- ``GestureRecognizer/cancelActiveGesture()`` +- ``GestureRecognizer/snapshot`` +- ``GestureRecognizer/observe(_:)`` +- ``GestureRecognizer/observeStatus(_:)`` +- ``GestureRecognizer/observeTrace(_:)`` - ``GestureRecognizerConfiguration`` +- ``GestureRecognizerConfiguration/init(makeMatcher:onReplayRequested:onMatch:recognitionButton:recognitionContext:isRecognitionEnabled:passesThroughEmptyMatcher:areModifiersSatisfied:tuning:)`` - ``GestureRecognitionContext`` +- ``GestureRecognitionContext/init(identifier:)`` +- ``GestureRecognitionContext/identifier`` - ``GestureRecognizerFailure`` +- ``GestureRecognizerFailure/eventSourceStartFailed`` +- ``GestureRecognizerFailure/recognitionDisabled`` +- ``GestureRecognizerFailure/modifiersNotSatisfied`` +- ``GestureRecognizerFailure/gestureSessionExpired`` ### Matching -- ``GesturePatternCatalog`` -- ``GesturePatternCatalog/ValidationIssue`` - ``GesturePatternMatcher`` +- ``GesturePatternMatcher/init()`` +- ``GesturePatternMatcher/register(pattern:match:)`` +- ``GesturePatternMatcher/match(pattern:)`` - ``GestureDirection`` +- ``GestureDirection/up`` +- ``GestureDirection/down`` +- ``GestureDirection/left`` +- ``GestureDirection/right`` ### Input and Geometry - ``GestureInputEvent`` +- ``GestureInputEvent/init(kind:location:modifiers:)`` +- ``GestureInputEvent/Kind`` +- ``GestureInputEvent/Kind/buttonDown(_:)`` +- ``GestureInputEvent/Kind/buttonMoved(_:)`` +- ``GestureInputEvent/Kind/buttonUp(_:)`` +- ``GestureInputEvent/Kind/cancel`` +- ``GestureInputEvent/kind`` +- ``GestureInputEvent/location`` +- ``GestureInputEvent/modifiers`` - ``GestureEventDisposition`` +- ``GestureEventDisposition/consume`` +- ``GestureEventDisposition/passThrough`` +- ``GestureEventDisposition/consumesOriginalEvent`` - ``GestureReplayRequest`` +- ``GestureReplayRequest/click(button:at:)`` +- ``GestureReplayRequest/drag(button:points:)`` +- ``GestureReplayRequest/dragStart(button:points:)`` +- ``GestureReplayRequest/release(button:at:)`` - ``PointerButton`` +- ``PointerButton/primary`` +- ``PointerButton/secondary`` +- ``PointerButton/middle`` +- ``PointerButton/init(auxiliaryButtonID:)`` +- ``PointerButton/auxiliaryButtonID`` - ``GesturePoint`` +- ``GesturePoint/init(x:y:)`` +- ``GesturePoint/x`` +- ``GesturePoint/y`` +- ``GesturePoint/zero`` - ``GestureModifierFlags`` +- ``GestureModifierFlags/init(rawValue:)`` +- ``GestureModifierFlags/rawValue`` +- ``GestureModifierFlags/command`` +- ``GestureModifierFlags/option`` +- ``GestureModifierFlags/control`` +- ``GestureModifierFlags/shift`` ### Policy and Tuning - ``GestureRecognizerTuning`` +- ``GestureRecognizerTuning/standard`` +- ``GestureRecognizerTuning/validated(minimumGestureStartAxisDistance:minimumDirectionChangeAxisDistance:maximumGestureSessionDuration:maximumRawPointCount:eventSourceStartRetryDelays:)`` +- ``GestureRecognizerTuning/ValidationError`` +- ``GestureRecognizerTuning/minimumGestureStartAxisDistance`` +- ``GestureRecognizerTuning/minimumDirectionChangeAxisDistance`` +- ``GestureRecognizerTuning/maximumGestureSessionDuration`` +- ``GestureRecognizerTuning/maximumRawPointCount`` +- ``GestureRecognizerTuning/eventSourceStartRetryDelays`` +- ``GestureRecognizer/updateTuning(_:)`` ### Observation - ``GestureRecognizerState`` +- ``GestureRecognizerState/init(status:trace:)`` +- ``GestureRecognizerState/status`` +- ``GestureRecognizerState/trace`` - ``GestureRecognizerState/Status`` +- ``GestureRecognizerState/Status/init(lifecycle:isCapturingGesture:isRecordingModeEnabled:lastRecordedDirections:lastFailure:)`` +- ``GestureRecognizerState/Status/lifecycle`` +- ``GestureRecognizerState/Status/isCapturingGesture`` +- ``GestureRecognizerState/Status/isRecordingModeEnabled`` +- ``GestureRecognizerState/Status/lastRecordedDirections`` +- ``GestureRecognizerState/Status/lastFailure`` - ``GestureRecognizerState/Status/Lifecycle`` +- ``GestureRecognizerState/Status/Lifecycle/idle`` +- ``GestureRecognizerState/Status/Lifecycle/starting`` +- ``GestureRecognizerState/Status/Lifecycle/ready`` +- ``GestureRecognizerState/Status/Lifecycle/retrying`` +- ``GestureRecognizerState/Status/Lifecycle/failed`` - ``GestureRecognizerState/Trace`` +- ``GestureRecognizerState/Trace/init(isVisible:rawPoints:directions:directionEndpoints:tailPoint:)`` +- ``GestureRecognizerState/Trace/isVisible`` +- ``GestureRecognizerState/Trace/rawPoints`` +- ``GestureRecognizerState/Trace/directions`` +- ``GestureRecognizerState/Trace/directionEndpoints`` +- ``GestureRecognizerState/Trace/tailPoint`` - ``GestureObservationToken`` +- ``GestureObservationToken/cancel()`` ### Event Source - ``GestureEventSource`` +- ``GestureEventSource/start(handler:)`` +- ``GestureEventSource/stop()`` diff --git a/Sources/PointerGestureKit/Recognizer/GestureObservationToken.swift b/Sources/PointerGestureKit/Recognizer/GestureObservationToken.swift deleted file mode 100644 index 09c02bb..0000000 --- a/Sources/PointerGestureKit/Recognizer/GestureObservationToken.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation - -/// A thread-safe cancellable token that keeps a recognizer observation active. -/// -/// `cancel()` may be called from any actor. Observer removal is performed on the main actor. -public final class GestureObservationToken: @unchecked Sendable { - private let lock = NSLock() - private var onCancel: (@MainActor @Sendable () -> Void)? - - init(onCancel: @escaping @MainActor @Sendable () -> Void) { - self.onCancel = onCancel - } - - deinit { - cancel() - } - - /// Cancels the observation if it is still active. - public func cancel() { - lock.lock() - let onCancel = onCancel - self.onCancel = nil - lock.unlock() - - guard let onCancel else { return } - if Thread.isMainThread { - MainActor.assumeIsolated { - onCancel() - } - } else { - Task { @MainActor in - onCancel() - } - } - } -} diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognitionContext.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognitionContext.swift index 14050f8..722a1ae 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognitionContext.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognitionContext.swift @@ -1,11 +1,11 @@ import Foundation /// Host-defined context used to select gesture policy and gesture matches. -public struct GestureRecognitionContext: Hashable, Sendable { - /// Stable host-owned identifier for the context. +public struct GestureRecognitionContext: Equatable, Sendable { + /// Host-owned identifier for the context. public let identifier: String - /// Creates a context with a stable host-owned identifier. + /// Creates a context with a host-owned identifier. /// /// Trims surrounding whitespace and returns `nil` when the trimmed identifier is empty. /// Use `nil` recognition contexts when no host context is active. diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+ButtonInputReplay.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+ButtonInputReplay.swift new file mode 100644 index 0000000..56cbd85 --- /dev/null +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+ButtonInputReplay.swift @@ -0,0 +1,129 @@ +extension GestureRecognizer { + func requestInterruptedButtonInputReplayIfNeeded() { + guard + let replayRequest = Self.interruptedButtonInputReplayRequest( + session: session, + pendingButtonInput: pendingButtonInput, + recognitionButton: configuration.recognitionButton, + minimumGestureStartAxisDistance: tuning.minimumGestureStartAxisDistance + ) + else { return } + + configuration.onReplayRequested(replayRequest) + } + + func requestConsumedButtonReplay(points: [GesturePoint], clickAt point: GesturePoint) { + configuration.onReplayRequested( + Self.consumedButtonReplayRequest( + button: configuration.recognitionButton, + points: points, + clickAt: point + )) + } + + nonisolated static func interruptedButtonInputReplayRequest( + session: GestureSession?, + pendingButtonInput: PendingButtonInput?, + recognitionButton: PointerButton, + minimumGestureStartAxisDistance: Double + ) -> GestureReplayRequest? { + if let session { + if let replayPoint = session.consumedButtonInput.fallbackReplayPoint { + return consumedButtonReplayRequest( + button: recognitionButton, + points: session.consumedButtonInput.points, + clickAt: replayPoint + ) + } + + if session.recognition.isCapturingGesture { + return .release( + button: recognitionButton, + at: interruptedReleasePoint(for: session) + ) + } + + return nil + } + + guard let pendingButtonInput else { return nil } + return pendingButtonInputReplayRequest( + pendingButtonInput, + button: recognitionButton, + minimumGestureStartAxisDistance: minimumGestureStartAxisDistance + ) + } + + nonisolated static func pendingButtonInputReplayRequest( + _ pendingButtonInput: PendingButtonInput, + button: PointerButton, + minimumGestureStartAxisDistance: Double + ) -> GestureReplayRequest { + guard + shouldReplayPendingButtonInputAsDrag( + pendingButtonInput, + minimumGestureStartAxisDistance: minimumGestureStartAxisDistance + ) + else { + return .click(button: button, at: pendingButtonInput.startPoint) + } + + return .drag(button: button, points: pendingButtonInput.consumedButtonPoints) + } + + private nonisolated static func shouldReplayPendingButtonInputAsDrag( + _ pendingButtonInput: PendingButtonInput, + minimumGestureStartAxisDistance: Double + ) -> Bool { + guard pendingButtonInput.consumedButtonPoints.count > 1 else { return false } + return pendingButtonInput.consumedButtonPoints.contains { + GestureMovementResolver.maximumAbsoluteAxisDelta( + from: pendingButtonInput.startPoint, + to: $0 + ) >= minimumGestureStartAxisDistance + } + } + + func ensureFallbackReplayPoint(in session: inout GestureSession) { + session.consumedButtonInput.fallbackReplayPoint = + session.consumedButtonInput.fallbackReplayPoint ?? session.startPoint + } + + func updateFallbackReplayPointIfGestureStopped( + _ session: inout GestureSession, + to point: GesturePoint + ) + -> Bool + { + guard session.recognition.isCapturingGesture else { + session.consumedButtonInput.fallbackReplayPoint = point + return true + } + return false + } + + func appendConsumedButtonPoint( + _ point: GesturePoint, + to session: inout GestureSession + ) { + if session.consumedButtonInput.points.last != point { + session.consumedButtonInput.points.append(point) + trimConsumedButtonPoints(&session.consumedButtonInput.points) + } + } + + func trimConsumedButtonPoints(_ points: inout [GesturePoint]) { + let maximumPointCount = max(2, tuning.maximumRawPointCount) + let overflow = points.count - maximumPointCount + guard overflow > 0 else { return } + points.removeSubrange(1..<(1 + overflow)) + } + + private nonisolated static func interruptedReleasePoint(for session: GestureSession) + -> GesturePoint + { + session.consumedButtonInput.fallbackReplayPoint + ?? session.trace.rawPoints.last + ?? session.startPoint + } +} diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+ConsumedButtonInput.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+ConsumedButtonInput.swift deleted file mode 100644 index 2c78d66..0000000 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+ConsumedButtonInput.swift +++ /dev/null @@ -1,61 +0,0 @@ -extension GestureRecognizer { - func requestConsumedButtonInputReplayIfNeeded() { - if let activeSession = session { - if activeSession.recognition.isCapturingGesture { - onReplayRequested( - .release(button: recognitionButton, at: releasePoint(for: activeSession))) - } else if let replayPoint = activeSession.consumedButtonInput.fallbackReplayPoint { - requestConsumedButtonInputReplay( - activeSession.consumedButtonInput.points, - clickAt: replayPoint - ) - } - return - } - - guard let pendingInput = pendingButtonInput else { return } - - requestConsumedButtonInputReplay( - pendingInput.consumedButtonPoints, - clickAt: pendingInput.startPoint - ) - } - - func requestConsumedButtonInputReplay(_ points: [GesturePoint], clickAt point: GesturePoint) { - onReplayRequested( - .consumedButtonInput(button: recognitionButton, points: points, clickAt: point)) - } - - func ensureFallbackReplayPoint(in session: inout GestureSession) { - session.consumedButtonInput.fallbackReplayPoint = - session.consumedButtonInput.fallbackReplayPoint ?? session.startPoint - } - - func updateFallbackReplayPointIfGestureStopped( - _ session: inout GestureSession, - to point: GesturePoint - ) - -> Bool - { - guard session.recognition.isCapturingGesture else { - session.consumedButtonInput.fallbackReplayPoint = point - return true - } - return false - } - - func appendConsumedButtonPoint( - _ point: GesturePoint, - to session: inout GestureSession - ) { - if session.consumedButtonInput.points.last != point { - session.consumedButtonInput.points.append(point) - } - } - - private func releasePoint(for session: GestureSession) -> GesturePoint { - session.consumedButtonInput.fallbackReplayPoint - ?? session.trace.rawPoints.last - ?? session.startPoint - } -} diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+EventHandling.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+EventHandling.swift index 3029108..ebbedaf 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+EventHandling.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+EventHandling.swift @@ -1,19 +1,21 @@ +import Foundation + extension GestureRecognizer { private var isTrackingButtonInput: Bool { pendingButtonInput != nil || session != nil } func handleInputEvent(_ event: GestureInputEvent) -> GestureEventDisposition { - guard isReady else { return .passThrough } + guard isReady, event.location.isFinite else { return .passThrough } switch event.kind { case let .buttonDown(button): - guard button == recognitionButton else { return .passThrough } - guard !isTrackingButtonInput else { - return .consume + guard button == configuration.recognitionButton else { return .passThrough } + if isTrackingButtonInput { + cancelGestureSession() } - let policyResult = validateGestureStartPolicy( + let policyResult = evaluateRecognitionPolicy( at: event.location, modifiers: event.modifiers ) @@ -26,12 +28,12 @@ extension GestureRecognizer { recognitionContext: recognitionContext ) - case let .buttonDragged(button): - guard button == recognitionButton else { return .passThrough } + case let .buttonMoved(button): + guard button == configuration.recognitionButton else { return .passThrough } return continueButtonInput(at: event.location) case let .buttonUp(button): - guard button == recognitionButton else { return .passThrough } + guard button == configuration.recognitionButton else { return .passThrough } return finishButtonInput(at: event.location) case .cancel: @@ -49,11 +51,24 @@ extension GestureRecognizer { at location: GesturePoint, recognitionContext: GestureRecognitionContext? ) -> GestureEventDisposition { + let isRecordingSession = isRecordingModeEnabled + let matcherCursor = isRecordingSession ? nil : configuration.makeMatcher(recognitionContext) + guard + isRecordingSession || !configuration.passesThroughEmptyMatcher + || matcherCursor?.isEmpty == false + else { + return .passThrough + } + + let inputID = UUID() pendingButtonInput = PendingButtonInput( + inputID: inputID, startPoint: location, consumedButtonPoints: [location], - recognitionContext: recognitionContext + matcherCursor: matcherCursor, + isRecordingSession: isRecordingSession ) + schedulePendingButtonInputExpirationIfNeeded(forInputID: inputID) return .consume } @@ -71,12 +86,16 @@ extension GestureRecognizer { beginGestureSession( at: pendingInput.startPoint, consumedPoints: pendingInput.consumedButtonPoints, - context: pendingInput.recognitionContext + matcherCursor: pendingInput.matcherCursor, + isRecordingSession: pendingInput.isRecordingSession ) pendingButtonInput = nil } updateGestureSession(to: location) + if releaseStoppedButtonInputIfNeeded() { + return .consume + } guard var activeSession = session else { return .passThrough } if shouldConsumeButtonInput(for: activeSession) { @@ -90,7 +109,7 @@ extension GestureRecognizer { private func finishButtonInput(at location: GesturePoint) -> GestureEventDisposition { guard let activeSession = session else { - if hasPendingButtonMovement { + if let pendingInput = pendingButtonInput, pendingInput.consumedButtonPoints.count > 1 { appendPendingButtonInputPoint(location) } return requestPendingButtonInputReplay() @@ -102,19 +121,24 @@ extension GestureRecognizer { } let outcome = completeGestureSession(at: location) + configuration.onReplayRequested(outcome.replayRequest) if let match = outcome.match { - onMatch(match) + configuration.onMatch(match) } - onReplayRequested(outcome.replayRequest) return .consume } private func requestPendingButtonInputReplay() -> GestureEventDisposition { guard let pendingInput = pendingButtonInput else { return .passThrough } pendingButtonInput = nil - requestConsumedButtonInputReplay( - pendingInput.consumedButtonPoints, - clickAt: pendingInput.startPoint + inputExpirationTask?.cancel() + inputExpirationTask = nil + configuration.onReplayRequested( + Self.pendingButtonInputReplayRequest( + pendingInput, + button: configuration.recognitionButton, + minimumGestureStartAxisDistance: tuning.minimumGestureStartAxisDistance + ) ) return .consume } @@ -123,34 +147,44 @@ extension GestureRecognizer { guard var pendingInput = pendingButtonInput else { return } if pendingInput.consumedButtonPoints.last != point { pendingInput.consumedButtonPoints.append(point) + trimConsumedButtonPoints(&pendingInput.consumedButtonPoints) } pendingButtonInput = pendingInput } - private var hasPendingButtonMovement: Bool { - guard let pendingInput = pendingButtonInput else { return false } - return pendingInput.consumedButtonPoints.count > 1 - } - private func shouldPromotePendingButtonInputToGesture( from start: GesturePoint, to next: GesturePoint ) -> Bool { - guard GestureMovementResolver.dominantDirection(from: start, to: next) != nil else { + guard GestureMovementResolver.dominantAxisDirection(from: start, to: next) != nil else { return false } - let movementThreshold: Double = - if isRecordingModeEnabled { - tuning.minimumGestureStartAxisDistance - } else { - max(tuning.minimumGestureStartAxisDistance, tuning.minimumDirectionChangeAxisDistance) - } - return GestureMovementResolver.maximumAxisDelta(from: start, to: next) > movementThreshold + return GestureMovementResolver.maximumAbsoluteAxisDelta(from: start, to: next) + >= tuning.minimumGestureStartAxisDistance } private func shouldConsumeButtonInput(for session: GestureSession) -> Bool { session.trace.isVisible || !session.trace.directions.isEmpty || session.consumedButtonInput.fallbackReplayPoint != nil } + + private func releaseStoppedButtonInputIfNeeded() -> Bool { + guard + let activeSession = session, + !activeSession.recognition.isCapturingGesture, + activeSession.consumedButtonInput.fallbackReplayPoint != nil + else { + return false + } + + configuration.onReplayRequested( + .dragStart( + button: configuration.recognitionButton, + points: activeSession.consumedButtonInput.points + ) + ) + resetGestureSession() + return true + } } diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Lifecycle.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Lifecycle.swift index 671a270..50db804 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Lifecycle.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Lifecycle.swift @@ -1,10 +1,11 @@ extension GestureRecognizer { /// Starts gesture recognition. /// - /// Calling this while the recognizer is already ready has no effect. When the event source cannot start, - /// the recognizer records ``GestureRecognizerFailure/eventSourceStartFailed``. It follows the - /// configured retry schedule when retry delays are present, or moves to a failed lifecycle when - /// no retry delay is configured. + /// Calling this while the recognizer is already ready has no effect. Calling it again after a + /// failed or retrying start request retries startup immediately. When the event source cannot + /// start, the recognizer records ``GestureRecognizerFailure/eventSourceStartFailed``. It follows + /// the configured retry schedule when retry delays are present, or moves to a failed lifecycle + /// when no retry delay is configured. public func start() { guard lifecycleState != .ready else { return } @@ -15,8 +16,8 @@ extension GestureRecognizer { /// Stops gesture recognition and clears active state. /// - /// This cancels pending startup retries, clears pending gesture state, and requests a release - /// replay when an active gesture session is holding consumed pointer-button input. + /// This cancels pending startup retries, clears pending gesture state, and requests the replay + /// needed when consumed pointer-button input is active. public func stop() { guard lifecycleState != .idle || startRequested else { return } @@ -38,6 +39,9 @@ extension GestureRecognizer { /// Cancels the active gesture session and releases any held pointer-button state. /// + /// When consumed pointer-button input is active, this requests the replay needed to leave host + /// input state consistent. + /// /// Calling this while no gesture session or pending button input exists has no effect and /// preserves the current failure state. public func cancelActiveGesture() { @@ -55,8 +59,8 @@ extension GestureRecognizer { func stopRuntimeResources() { cancelStartupRetry() - requestConsumedButtonInputReplayIfNeeded() - resetGestureSession() + requestInterruptedButtonInputReplayIfNeeded() + resetGestureSession(discardPendingVisibleTraceNotifications: true) eventSource.stop() } @@ -81,30 +85,34 @@ extension GestureRecognizer { lastFailure = .eventSourceStartFailed } - private func scheduleStartupRetry() { + func scheduleStartupRetry() { cancelStartupRetry() guard startRequested, !tuning.eventSourceStartRetryDelays.isEmpty else { return } - startupRetryTask = Task { [weak self] in - guard let self else { return } + startupRetryTask = Task { @MainActor [weak self] in var attempt = 0 while !Task.isCancelled { - let retryDelays = tuning.eventSourceStartRetryDelays + guard let retryDelays = self?.tuning.eventSourceStartRetryDelays else { return } + guard !retryDelays.isEmpty else { + self?.recordEventSourceStartFailure() + self?.startupRetryTask = nil + return + } let delay = retryDelays[min(attempt, retryDelays.count - 1)] attempt += 1 if delay > 0 { - try? await Task.sleep(nanoseconds: GestureRecognizerTiming.sleepNanoseconds(for: delay)) + try? await Task.sleep( + nanoseconds: GestureRecognizerTiming.taskSleepNanoseconds(for: delay)) } else { await Task.yield() } guard !Task.isCancelled else { return } - let retryLoopDidFinish = await MainActor.run { - self.attemptScheduledStartupRetry() - } + guard let self else { return } + let retryLoopDidFinish = attemptScheduledStartupRetry() if retryLoopDidFinish { return @@ -139,7 +147,7 @@ extension GestureRecognizer { } } - private func cancelStartupRetry() { + func cancelStartupRetry() { startupRetryTask?.cancel() startupRetryTask = nil } diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Observation.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Observation.swift deleted file mode 100644 index c1957fa..0000000 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Observation.swift +++ /dev/null @@ -1,157 +0,0 @@ -import Foundation - -extension GestureRecognizer { - /// Current recognizer state snapshot. - public var snapshot: GestureRecognizerState { - GestureRecognizerState( - status: currentStatus, - trace: currentTrace - ) - } - - private var currentStatus: GestureRecognizerState.Status { - GestureRecognizerState.Status( - lifecycle: publicLifecycleState(for: lifecycleState), - isCapturingGesture: isCapturingGesture, - isRecordingModeEnabled: isRecordingModeEnabled, - lastRecordedDirections: lastRecordedDirections, - lastFailure: lastFailure - ) - } - - private var currentTrace: GestureRecognizerState.Trace { - GestureRecognizerState.Trace( - isVisible: traceIsVisible, - rawPoints: traceRawPoints, - directions: traceDirections, - directionEndpoints: traceDirectionEndpoints - ) - } - - /// Observes combined recognizer state changes. - @discardableResult - public func observe(_ handler: @escaping @MainActor @Sendable (GestureRecognizerState) -> Void) - -> GestureObservationToken - { - let id = UUID() - snapshotObservers[id] = handler - handler(snapshot) - - return GestureObservationToken(onCancel: { [weak self] in - self?.snapshotObservers.removeValue(forKey: id) - }) - } - - /// Observes the status portion of the recognizer snapshot. - @discardableResult - public func observeStatus( - _ handler: @escaping @MainActor @Sendable (GestureRecognizerState.Status) -> Void - ) -> GestureObservationToken { - let observer = DistinctStateObserver() - return observe { state in - observer.deliver(state.status, to: handler) - } - } - - /// Observes the trace portion of the recognizer snapshot. - @discardableResult - public func observeTrace( - _ handler: @escaping @MainActor @Sendable (GestureRecognizerState.Trace) -> Void - ) -> GestureObservationToken { - let observer = DistinctStateObserver() - return observe { state in - observer.deliver(state.trace, to: handler) - } - } - - private func scheduleSnapshotNotification() { - guard !snapshotObservers.isEmpty else { return } - guard snapshotNotificationTask == nil else { return } - - snapshotNotificationTask = Task { @MainActor [weak self] in - await Task.yield() - guard !Task.isCancelled else { return } - guard let self else { return } - - snapshotNotificationTask = nil - let current = snapshot - let observers = Array(snapshotObservers.values) - for observer in observers { - observer(current) - } - } - } - - func scheduleSnapshotNotificationIfChanged( - from oldValue: Value, - to newValue: Value - ) { - guard oldValue != newValue else { return } - scheduleSnapshotNotification() - } - - func cancelSnapshotNotification() { - snapshotNotificationTask?.cancel() - snapshotNotificationTask = nil - } - - func updatePublishedState(from session: GestureSession, publishRawPoints: Bool) { - var publishedRawPoints: [GesturePoint] - if publishRawPoints { - publishedRawPoints = session.trace.rawPoints - traceRawPoints = publishedRawPoints - } else if !traceRawPoints.isEmpty { - publishedRawPoints = traceRawPoints - } else { - publishedRawPoints = session.trace.rawPoints - traceRawPoints = publishedRawPoints - } - - traceDirectionEndpoints = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: session.trace.directionEndpoints, - recognizedDirectionCount: session.trace.directions.count, - publishedRawPoints: publishedRawPoints - ) - traceDirections = session.trace.directions - traceIsVisible = session.trace.isVisible - isCapturingGesture = session.recognition.isCapturingGesture - } - - func clearPublishedState() { - traceRawPoints = [] - traceDirections = [] - traceDirectionEndpoints = [] - traceIsVisible = false - isCapturingGesture = false - } -} - -private func publicLifecycleState( - for lifecycleState: GestureRecognizer.LifecycleState -) -> GestureRecognizerState.Status.Lifecycle { - switch lifecycleState { - case .idle: - return .idle - case .starting: - return .starting - case .ready: - return .ready - case .retrying: - return .retrying - case .failed: - return .failed - } -} - -@MainActor -private final class DistinctStateObserver { - private var lastValue: Value? - - func deliver(_ value: Value, to handler: @MainActor @Sendable (Value) -> Void) { - if let lastValue, lastValue == value { - return - } - lastValue = value - handler(value) - } -} diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+RecognitionPolicy.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+RecognitionPolicy.swift index 2bc9fe4..9da99a5 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+RecognitionPolicy.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+RecognitionPolicy.swift @@ -1,21 +1,21 @@ extension GestureRecognizer { - enum GestureStartPolicyResult { + enum RecognitionPolicyResult { case allowed(context: GestureRecognitionContext?) case denied } - func validateGestureStartPolicy( + func evaluateRecognitionPolicy( at location: GesturePoint, modifiers: GestureModifierFlags - ) -> GestureStartPolicyResult { - let context = recognitionContext(location) + ) -> RecognitionPolicyResult { + let context = configuration.recognitionContext(location) - guard isRecognitionEnabled(context) else { + guard configuration.isRecognitionEnabled(context) else { lastFailure = .recognitionDisabled return .denied } - guard isRecordingModeEnabled || areModifiersSatisfied(modifiers, context) else { + guard isRecordingModeEnabled || configuration.areModifiersSatisfied(modifiers, context) else { lastFailure = .modifiersNotSatisfied return .denied } diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Session.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Session.swift index d3a9b4e..264fdf9 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Session.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Session.swift @@ -9,9 +9,9 @@ extension GestureRecognizer { func beginGestureSession( at point: GesturePoint, consumedPoints: [GesturePoint], - context: GestureRecognitionContext? + matcherCursor: GesturePatternMatcher?, + isRecordingSession: Bool ) { - lastRecordedDirections = [] let sessionID = UUID() let newSession = GestureSession( sessionID: sessionID, @@ -21,10 +21,12 @@ extension GestureRecognizer { publishedRawPointCount: 1, directionEndpoints: [point], directions: [], + tailPoint: point, isVisible: false ), recognition: GestureSessionRecognitionState( - matcherCursor: makeMatcher(context), + matcherCursor: isRecordingSession ? nil : matcherCursor, + isRecordingSession: isRecordingSession, isCapturingGesture: true ), consumedButtonInput: ConsumedButtonInputState( @@ -35,12 +37,12 @@ extension GestureRecognizer { ) session = newSession updatePublishedState(from: newSession, publishRawPoints: true) - scheduleSessionExpirationIfNeeded(for: sessionID) + scheduleSessionExpirationIfNeeded(forSessionID: sessionID) } func updateGestureSession( to next: GesturePoint, - forcePublish: Bool = false + publishRawPointsImmediately: Bool = false ) { guard isReady, var currentSession = session else { return } @@ -54,25 +56,38 @@ extension GestureRecognizer { guard let lastDirectionEndpoint = currentSession.trace.directionEndpoints.last else { return } currentSession.trace.rawPoints.append(next) - trimBuffer(¤tSession) + currentSession.trace.tailPoint = next + trimRawPointBuffer(¤tSession) - currentSession.trace.isVisible = - currentSession.trace.isVisible || shouldShowTrace(for: currentSession, next: next) + let hasReachedStartDistance = hasReachedTraceStartDistance(for: currentSession, next: next) - let didAcceptNewDirection = updateDirectionState( + let previousDirectionCount = currentSession.trace.directions.count + let didUpdateTraceGeometry = advanceGestureSession( for: ¤tSession, next: next, lastDirectionEndpoint: lastDirectionEndpoint ) + let didAppendDirection = currentSession.trace.directions.count > previousDirectionCount + currentSession.trace.isVisible = shouldRevealTrace( + for: currentSession, + hasReachedStartDistance: hasReachedStartDistance + ) + let stoppedCapturingGesture = !currentSession.recognition.isCapturingGesture + let shouldPublishTailPoint = currentSession.trace.isVisible let publishRawPoints = - forcePublish || shouldPublishRawPoints(in: currentSession) || didAcceptNewDirection + publishRawPointsImmediately || shouldPublishRawPointBatch(in: currentSession) + || didAppendDirection || stoppedCapturingGesture if publishRawPoints { currentSession.trace.publishedRawPointCount = currentSession.trace.rawPoints.count } session = currentSession - updatePublishedState(from: currentSession, publishRawPoints: publishRawPoints) + updatePublishedState( + from: currentSession, + publishRawPoints: publishRawPoints, + notifyTrace: publishRawPoints || didUpdateTraceGeometry || shouldPublishTailPoint + ) } func completeGestureSession( @@ -96,13 +111,13 @@ extension GestureRecognizer { var stoppedSession = activeSession appendConsumedButtonPoint(finalPoint, to: &stoppedSession) let points = stoppedSession.consumedButtonInput.points - return replayedConsumedButtonInputOutcome( + return consumedButtonReplayOutcome( points: points, clickAt: consumedButtonClickReplayPoint(for: stoppedSession) ) } - updateGestureSession(to: finalPoint, forcePublish: true) + updateGestureSession(to: finalPoint, publishRawPointsImmediately: true) guard var currentSession = session else { return clickOutcome(at: finalPoint) @@ -117,16 +132,16 @@ extension GestureRecognizer { if let fallbackReplayPoint = currentSession.consumedButtonInput.fallbackReplayPoint { let points = currentSession.consumedButtonInput.points - return replayedConsumedButtonInputOutcome( + return consumedButtonReplayOutcome( points: points, clickAt: fallbackReplayPoint, match: match ) } - if isRecordingModeEnabled { + if currentSession.recognition.isRecordingSession { let points = currentSession.consumedButtonInput.points - return replayedConsumedButtonInputOutcome( + return consumedButtonReplayOutcome( points: points, clickAt: currentSession.startPoint, match: match @@ -136,17 +151,18 @@ extension GestureRecognizer { return releaseOutcome(at: finalPoint, match: match) } - func resetGestureSession() { + func resetGestureSession(discardPendingVisibleTraceNotifications: Bool = false) { pendingButtonInput = nil session = nil - clearPublishedState() - sessionExpirationTask?.cancel() - sessionExpirationTask = nil + clearPublishedState( + discardPendingVisibleTraceNotifications: discardPendingVisibleTraceNotifications) + inputExpirationTask?.cancel() + inputExpirationTask = nil } func cancelGestureSession() { - requestConsumedButtonInputReplayIfNeeded() - resetGestureSession() + requestInterruptedButtonInputReplayIfNeeded() + resetGestureSession(discardPendingVisibleTraceNotifications: true) lastFailure = nil } @@ -158,12 +174,12 @@ extension GestureRecognizer { return nil } - if let currentMatch = session.recognition.matcherCursor?.currentMatch { + if let terminalMatch = session.recognition.matcherCursor?.completedMatch() { session.consumedButtonInput.fallbackReplayPoint = nil - return currentMatch + return terminalMatch } - if !isRecordingModeEnabled { + if !session.recognition.isRecordingSession { ensureFallbackReplayPoint(in: &session) } @@ -174,7 +190,10 @@ extension GestureRecognizer { at point: GesturePoint, match: Match? = nil ) -> GestureSessionOutcome { - GestureSessionOutcome(replayRequest: .click(button: recognitionButton, at: point), match: match) + GestureSessionOutcome( + replayRequest: .click(button: configuration.recognitionButton, at: point), + match: match + ) } private func releaseOutcome( @@ -182,19 +201,19 @@ extension GestureRecognizer { match: Match? = nil ) -> GestureSessionOutcome { GestureSessionOutcome( - replayRequest: .release(button: recognitionButton, at: point), + replayRequest: .release(button: configuration.recognitionButton, at: point), match: match ) } - private func replayedConsumedButtonInputOutcome( + private func consumedButtonReplayOutcome( points: [GesturePoint], clickAt point: GesturePoint, match: Match? = nil ) -> GestureSessionOutcome { GestureSessionOutcome( - replayRequest: .consumedButtonInput( - button: recognitionButton, + replayRequest: Self.consumedButtonReplayRequest( + button: configuration.recognitionButton, points: points, clickAt: point ), diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+SessionExpiration.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+SessionExpiration.swift index 5ac6af6..597dbed 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+SessionExpiration.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+SessionExpiration.swift @@ -1,32 +1,58 @@ import Foundation extension GestureRecognizer { - func scheduleSessionExpirationIfNeeded(for sessionID: UUID) { + func schedulePendingButtonInputExpirationIfNeeded(forInputID inputID: UUID) { guard let maximumGestureSessionDuration = tuning.maximumGestureSessionDuration else { return } - sessionExpirationTask?.cancel() - sessionExpirationTask = Task { [weak self] in + inputExpirationTask?.cancel() + inputExpirationTask = Task { @MainActor [weak self] in do { try await Task.sleep( - nanoseconds: GestureRecognizerTiming.sleepNanoseconds( + nanoseconds: GestureRecognizerTiming.taskSleepNanoseconds( for: maximumGestureSessionDuration)) } catch { return } - await MainActor.run { - self?.expireSessionIfCurrent(sessionID) + self?.expirePendingButtonInputIfCurrentInputID(inputID) + } + } + + func scheduleSessionExpirationIfNeeded(forSessionID sessionID: UUID) { + guard let maximumGestureSessionDuration = tuning.maximumGestureSessionDuration else { return } + + inputExpirationTask?.cancel() + inputExpirationTask = Task { @MainActor [weak self] in + do { + try await Task.sleep( + nanoseconds: GestureRecognizerTiming.taskSleepNanoseconds( + for: maximumGestureSessionDuration)) + } catch { + return } + + self?.expireSessionIfCurrentSessionID(sessionID) } } @MainActor - private func expireSessionIfCurrent(_ sessionID: UUID) { + private func expirePendingButtonInputIfCurrentInputID(_ inputID: UUID) { + guard let pendingButtonInput, pendingButtonInput.inputID == inputID else { return } + + inputExpirationTask = nil + lastFailure = .gestureSessionExpired + requestInterruptedButtonInputReplayIfNeeded() + self.pendingButtonInput = nil + clearPublishedState(discardPendingVisibleTraceNotifications: true) + } + + @MainActor + private func expireSessionIfCurrentSessionID(_ sessionID: UUID) { guard let activeSession = session, activeSession.sessionID == sessionID else { return } - sessionExpirationTask = nil + inputExpirationTask = nil lastFailure = .gestureSessionExpired - requestConsumedButtonInputReplayIfNeeded() - resetGestureSession() + requestInterruptedButtonInputReplayIfNeeded() + resetGestureSession(discardPendingVisibleTraceNotifications: true) } } diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+SessionProgression.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+SessionProgression.swift deleted file mode 100644 index 419fc67..0000000 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+SessionProgression.swift +++ /dev/null @@ -1,72 +0,0 @@ -extension GestureRecognizer { - private static var rawPointPublishBatchSize: Int { 10 } - - func trimBuffer(_ session: inout GestureSession) { - let overflow = session.trace.rawPoints.count - tuning.maximumRawPointCount - guard overflow > 0 else { return } - session.trace.rawPoints.removeFirst(overflow) - session.trace.publishedRawPointCount = max(0, session.trace.publishedRawPointCount - overflow) - } - - func shouldPublishRawPoints(in session: GestureSession) -> Bool { - (session.trace.rawPoints.count - session.trace.publishedRawPointCount) - >= Self.rawPointPublishBatchSize - } - - func shouldShowTrace(for session: GestureSession, next: GesturePoint) -> Bool { - let movementThreshold = tuning.minimumGestureStartAxisDistance - return GestureMovementResolver.maximumAxisDelta(from: session.startPoint, to: next) - > movementThreshold - } - - func updateDirectionState( - for session: inout GestureSession, - next: GesturePoint, - lastDirectionEndpoint: GesturePoint - ) -> Bool { - guard - let newDirection = GestureMovementResolver.dominantDirection( - from: lastDirectionEndpoint, - to: next - ) - else { - return false - } - - if let lastDirection = session.trace.directions.last, lastDirection == newDirection { - session.trace.directionEndpoints[session.trace.directionEndpoints.count - 1] = next - return false - } - - if !session.trace.directions.isEmpty { - let directionThreshold = tuning.minimumDirectionChangeAxisDistance - guard - GestureMovementResolver.maximumAxisDelta(from: lastDirectionEndpoint, to: next) - > directionThreshold - else { - return false - } - } - - if isRecordingModeEnabled { - session.trace.directions.append(newDirection) - session.trace.directionEndpoints.append(next) - return true - } - - session.recognition.matcherCursor = - session.recognition.matcherCursor?.advanced(to: newDirection) - - guard session.recognition.matcherCursor != nil else { - session.recognition.isCapturingGesture = false - session.trace.isVisible = false - session.recognition.matcherCursor = nil - ensureFallbackReplayPoint(in: &session) - return false - } - - session.trace.directions.append(newDirection) - session.trace.directionEndpoints.append(next) - return true - } -} diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+InternalState.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+SessionState.swift similarity index 85% rename from Sources/PointerGestureKit/Recognizer/GestureRecognizer+InternalState.swift rename to Sources/PointerGestureKit/Recognizer/GestureRecognizer+SessionState.swift index b37e9a3..2253513 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+InternalState.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+SessionState.swift @@ -19,6 +19,7 @@ extension GestureRecognizer { struct GestureSessionRecognitionState { var matcherCursor: GesturePatternMatcher? + var isRecordingSession: Bool var isCapturingGesture: Bool } @@ -27,6 +28,7 @@ extension GestureRecognizer { var publishedRawPointCount: Int var directionEndpoints: [GesturePoint] var directions: [GestureDirection] + var tailPoint: GesturePoint? var isVisible: Bool } @@ -37,8 +39,10 @@ extension GestureRecognizer { } struct PendingButtonInput { + var inputID: UUID var startPoint: GesturePoint var consumedButtonPoints: [GesturePoint] - var recognitionContext: GestureRecognitionContext? + var matcherCursor: GesturePatternMatcher? + var isRecordingSession: Bool } } diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+TraceProgression.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+TraceProgression.swift new file mode 100644 index 0000000..3064edf --- /dev/null +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizer+TraceProgression.swift @@ -0,0 +1,99 @@ +extension GestureRecognizer { + private static var rawPointPublicationBatchSize: Int { 10 } + + func trimRawPointBuffer(_ session: inout GestureSession) { + let overflow = session.trace.rawPoints.count - tuning.maximumRawPointCount + guard overflow > 0 else { return } + session.trace.rawPoints.removeFirst(overflow) + session.trace.publishedRawPointCount = max(0, session.trace.publishedRawPointCount - overflow) + } + + func shouldPublishRawPointBatch(in session: GestureSession) -> Bool { + (session.trace.rawPoints.count - session.trace.publishedRawPointCount) + >= Self.rawPointPublicationBatchSize + } + + func hasReachedTraceStartDistance(for session: GestureSession, next: GesturePoint) -> Bool { + let movementThreshold = tuning.minimumGestureStartAxisDistance + return GestureMovementResolver.maximumAbsoluteAxisDelta(from: session.startPoint, to: next) + >= movementThreshold + } + + func shouldRevealTrace( + for session: GestureSession, + hasReachedStartDistance: Bool + ) -> Bool { + guard hasReachedStartDistance else { return false } + guard !session.trace.directions.isEmpty else { return false } + guard !session.recognition.isRecordingSession else { return true } + return session.recognition.matcherCursor != nil + } + + func shouldPublishInitialVisibleTraceImmediately(for session: GestureSession) -> Bool { + guard session.trace.isVisible else { return false } + guard !session.trace.directions.isEmpty else { return false } + if session.recognition.isRecordingSession { + return true + } + return session.recognition.matcherCursor != nil + } + + func advanceGestureSession( + for session: inout GestureSession, + next: GesturePoint, + lastDirectionEndpoint: GesturePoint + ) -> Bool { + guard + let newDirection = GestureMovementResolver.dominantAxisDirection( + from: lastDirectionEndpoint, + to: next + ) + else { + return false + } + + if let lastDirection = session.trace.directions.last, lastDirection == newDirection { + session.trace.directionEndpoints[session.trace.directionEndpoints.count - 1] = next + return true + } + + if !session.trace.directions.isEmpty { + let directionThreshold = tuning.minimumDirectionChangeAxisDistance + guard + GestureMovementResolver.maximumAbsoluteAxisDelta(from: lastDirectionEndpoint, to: next) + >= directionThreshold + else { + return false + } + } + + if session.recognition.isRecordingSession { + session.trace.directions.append(newDirection) + session.trace.directionEndpoints.append(next) + return true + } + + guard let matcherCursor = session.recognition.matcherCursor?.advanced(to: newDirection) else { + discardPendingVisibleTraceNotifications() + session.recognition.isCapturingGesture = false + session.recognition.matcherCursor = nil + clearSessionTrace(&session) + ensureFallbackReplayPoint(in: &session) + return true + } + session.recognition.matcherCursor = matcherCursor + + session.trace.directions.append(newDirection) + session.trace.directionEndpoints.append(next) + return true + } + + private func clearSessionTrace(_ session: inout GestureSession) { + session.trace.rawPoints = [] + session.trace.publishedRawPointCount = 0 + session.trace.directionEndpoints = [] + session.trace.directions = [] + session.trace.tailPoint = nil + session.trace.isVisible = false + } +} diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizer.swift index df6bc4a..bb041fd 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizer.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizer.swift @@ -24,7 +24,11 @@ public final class GestureRecognizer { } } - /// Whether the recognizer records directions without requiring modifier approval or a match. + /// Whether the recognizer records directions without requiring modifier approval or a matcher. + /// + /// Recording mode still respects the configured recognition-enabled policy for the active + /// context. Changes apply to gesture sessions that start after the value changes; an active + /// session keeps the mode it started with. public var isRecordingModeEnabled: Bool = false { didSet { scheduleSnapshotNotificationIfChanged(from: oldValue, to: isRecordingModeEnabled) @@ -69,36 +73,49 @@ public final class GestureRecognizer { } } + var traceTailPoint: GesturePoint? { + didSet { + scheduleSnapshotNotificationIfChanged(from: oldValue, to: traceTailPoint) + } + } + // MARK: - Observation State - var snapshotObservers: [UUID: @MainActor @Sendable (GestureRecognizerState) -> Void] = [:] - var snapshotNotificationTask: Task? + let observationCenter = GestureRecognizerObservationCenter() + var suppressSnapshotNotifications = false // MARK: - Runtime Configuration let eventSource: any GestureEventSource - let tuning: GestureRecognizerTuning + let configuration: GestureRecognizerConfiguration + var tuning: GestureRecognizerTuning { + didSet { + refreshDeinitializationReplayRequest() + } + } // MARK: - Runtime State - var session: GestureSession? - var startupRetryTask: Task? + let cleanup: GestureRecognizerCleanup + var session: GestureSession? { + didSet { + refreshDeinitializationReplayRequest() + } + } + var startupRetryTask: Task? { + get { cleanup.startupRetryTask } + set { cleanup.startupRetryTask = newValue } + } var startRequested = false - var sessionExpirationTask: Task? - var pendingButtonInput: PendingButtonInput? - - // MARK: - Recognition Configuration - - let makeMatcher: @MainActor @Sendable (GestureRecognitionContext?) -> GesturePatternMatcher - let recognitionButton: PointerButton - let onReplayRequested: @MainActor @Sendable (GestureReplayRequest) -> Void - let onMatch: @MainActor @Sendable (Match) -> Void - let recognitionContext: @MainActor @Sendable (GesturePoint) -> GestureRecognitionContext? - let isRecognitionEnabled: @MainActor @Sendable (GestureRecognitionContext?) -> Bool - let areModifiersSatisfied: - @MainActor @Sendable ( - GestureModifierFlags, GestureRecognitionContext? - ) -> Bool + var inputExpirationTask: Task? { + get { cleanup.inputExpirationTask } + set { cleanup.inputExpirationTask = newValue } + } + var pendingButtonInput: PendingButtonInput? { + didSet { + refreshDeinitializationReplayRequest() + } + } // MARK: - Initialization @@ -108,93 +125,67 @@ public final class GestureRecognizer { configuration: GestureRecognizerConfiguration ) { self.eventSource = eventSource + self.configuration = configuration tuning = configuration.tuning - makeMatcher = configuration.makeMatcher - recognitionButton = configuration.recognitionButton - onReplayRequested = configuration.onReplayRequested - onMatch = configuration.onMatch - recognitionContext = configuration.recognitionContext - isRecognitionEnabled = configuration.isRecognitionEnabled - areModifiersSatisfied = configuration.areModifiersSatisfied + cleanup = GestureRecognizerCleanup( + eventSource: eventSource, + onReplayRequested: configuration.onReplayRequested + ) } - deinit { - let cleanup = GestureRecognizerDeinitCleanup( - eventSource: eventSource, - startupRetryTask: startupRetryTask, - sessionExpirationTask: sessionExpirationTask, - snapshotNotificationTask: snapshotNotificationTask, - replayRequest: Self.deinitReplayRequest( - session: session, - pendingButtonInput: pendingButtonInput, - recognitionButton: recognitionButton - ), - onReplayRequested: onReplayRequested + private func refreshDeinitializationReplayRequest() { + cleanup.replayRequest = Self.interruptedButtonInputReplayRequest( + session: session, + pendingButtonInput: pendingButtonInput, + recognitionButton: configuration.recognitionButton, + minimumGestureStartAxisDistance: tuning.minimumGestureStartAxisDistance ) + } - if Thread.isMainThread { - MainActor.assumeIsolated { - cleanup.run() - } - } else { - Task { @MainActor in - cleanup.run() - } - } + nonisolated static func consumedButtonReplayRequest( + button: PointerButton, + points: [GesturePoint], + clickAt point: GesturePoint + ) -> GestureReplayRequest { + points.count > 1 + ? .drag(button: button, points: points) + : .click(button: button, at: point) + } + + /// Updates recognition thresholds, runtime limits, and startup retry tuning. + /// + /// Existing gesture-input expiration and startup retry work is rescheduled only when the value + /// changes. The return value lets host apps avoid canceling active input for duplicate updates. + @discardableResult + public func updateTuning(_ tuning: GestureRecognizerTuning) -> Bool { + guard self.tuning != tuning else { return false } + + self.tuning = tuning + rescheduleInputExpirationAfterTuningUpdate() + rescheduleStartupRetryAfterTuningUpdate() + return true } - private nonisolated static func deinitReplayRequest( - session: GestureSession?, - pendingButtonInput: PendingButtonInput?, - recognitionButton: PointerButton - ) -> GestureReplayRequest? { + private func rescheduleInputExpirationAfterTuningUpdate() { + inputExpirationTask?.cancel() + inputExpirationTask = nil + if let session { - if session.recognition.isCapturingGesture { - return .release( - button: recognitionButton, - at: deinitReleasePoint(for: session) - ) - } - - guard let replayPoint = session.consumedButtonInput.fallbackReplayPoint else { return nil } - return .consumedButtonInput( - button: recognitionButton, - points: session.consumedButtonInput.points, - clickAt: replayPoint - ) + scheduleSessionExpirationIfNeeded(forSessionID: session.sessionID) + return } - guard let pendingButtonInput else { return nil } - return .consumedButtonInput( - button: recognitionButton, - points: pendingButtonInput.consumedButtonPoints, - clickAt: pendingButtonInput.startPoint - ) + if let pendingButtonInput { + schedulePendingButtonInputExpirationIfNeeded(forInputID: pendingButtonInput.inputID) + return + } } - private nonisolated static func deinitReleasePoint(for session: GestureSession) -> GesturePoint { - session.consumedButtonInput.fallbackReplayPoint - ?? session.trace.rawPoints.last - ?? session.startPoint - } -} + private func rescheduleStartupRetryAfterTuningUpdate() { + guard startRequested, !isReady else { return } -private struct GestureRecognizerDeinitCleanup: @unchecked Sendable { - let eventSource: any GestureEventSource - let startupRetryTask: Task? - let sessionExpirationTask: Task? - let snapshotNotificationTask: Task? - let replayRequest: GestureReplayRequest? - let onReplayRequested: @MainActor @Sendable (GestureReplayRequest) -> Void - - @MainActor - func run() { - startupRetryTask?.cancel() - if let replayRequest { - onReplayRequested(replayRequest) - } - sessionExpirationTask?.cancel() - snapshotNotificationTask?.cancel() - eventSource.stop() + cancelStartupRetry() + lifecycleState = tuning.eventSourceStartRetryDelays.isEmpty ? .failed : .retrying + scheduleStartupRetry() } } diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizerCleanup.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizerCleanup.swift new file mode 100644 index 0000000..5d8b176 --- /dev/null +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizerCleanup.swift @@ -0,0 +1,25 @@ +@MainActor +final class GestureRecognizerCleanup { + let eventSource: any GestureEventSource + let onReplayRequested: @MainActor @Sendable (GestureReplayRequest) -> Void + var startupRetryTask: Task? + var inputExpirationTask: Task? + var replayRequest: GestureReplayRequest? + + init( + eventSource: any GestureEventSource, + onReplayRequested: @escaping @MainActor @Sendable (GestureReplayRequest) -> Void + ) { + self.eventSource = eventSource + self.onReplayRequested = onReplayRequested + } + + isolated deinit { + startupRetryTask?.cancel() + inputExpirationTask?.cancel() + if let replayRequest { + onReplayRequested(replayRequest) + } + eventSource.stop() + } +} diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizerConfiguration.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizerConfiguration.swift index fad99f7..85f0cd4 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizerConfiguration.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizerConfiguration.swift @@ -1,6 +1,8 @@ /// Host-owned callbacks, policy, and tuning used to create a ``GestureRecognizer``. public struct GestureRecognizerConfiguration: Sendable { /// Returns a matcher for the active host-defined recognition context. + /// + /// Recording mode records directions without requesting a matcher. let makeMatcher: @MainActor @Sendable (GestureRecognitionContext?) -> GesturePatternMatcher /// The pointer button that starts gesture recognition. let recognitionButton: PointerButton @@ -11,8 +13,14 @@ public struct GestureRecognizerConfiguration: Sendable { /// Returns the host-defined recognition context for a pointer location. let recognitionContext: @MainActor @Sendable (GesturePoint) -> GestureRecognitionContext? /// Returns whether gestures may run for the active context. + /// + /// This policy is evaluated in both normal recognition and recording mode. let isRecognitionEnabled: @MainActor @Sendable (GestureRecognitionContext?) -> Bool - /// Returns whether the current modifier keys satisfy the host policy. + /// Whether normal recognition should pass input through when no pattern can match. + let passesThroughEmptyMatcher: Bool + /// Returns whether the current modifier flags satisfy the host policy. + /// + /// Recording mode bypasses this policy after recognition is enabled for the active context. let areModifiersSatisfied: @MainActor @Sendable (GestureModifierFlags, GestureRecognitionContext?) -> Bool /// Recognition thresholds, runtime limits, and event-source start retry tuning. @@ -21,13 +29,18 @@ public struct GestureRecognizerConfiguration: Sendable { /// Creates a recognizer configuration from host-owned callbacks, policy, and tuning. /// /// - Parameters: - /// - makeMatcher: Returns the direction matcher for the active host-defined context. + /// - makeMatcher: Returns the direction matcher for the active host-defined context. Recording + /// mode records directions without requesting a matcher. /// - onReplayRequested: Handles replay requests for consumed pointer-button input. /// - onMatch: Handles the app-owned match value for a recognized pattern. /// - recognitionButton: The pointer button that starts gesture recognition. /// - recognitionContext: Returns the host-defined context for the gesture start location. - /// - isRecognitionEnabled: Returns whether recognition may run for the active context. - /// - areModifiersSatisfied: Returns whether the current modifiers satisfy host policy. + /// - isRecognitionEnabled: Returns whether recognition may run for the active context. This + /// policy is evaluated in both normal recognition and recording mode. + /// - passesThroughEmptyMatcher: When true, normal recognition leaves pointer-button input + /// untouched if the matcher for the active context has no registered patterns. + /// - areModifiersSatisfied: Returns whether the current modifier flags satisfy host policy. + /// Recording mode bypasses this policy after recognition is enabled for the active context. /// - tuning: Recognition thresholds, runtime limits, and event-source start retry tuning. public init( makeMatcher: @@ -40,11 +53,12 @@ public struct GestureRecognizerConfiguration: Sendable { isRecognitionEnabled: @escaping @MainActor @Sendable (GestureRecognitionContext?) -> Bool = { _ in true }, + passesThroughEmptyMatcher: Bool = false, areModifiersSatisfied: @escaping @MainActor @Sendable (GestureModifierFlags, GestureRecognitionContext?) -> Bool = { _, _ in true }, - tuning: GestureRecognizerTuning = .init() + tuning: GestureRecognizerTuning = .standard ) { self.makeMatcher = makeMatcher self.recognitionButton = recognitionButton @@ -52,6 +66,7 @@ public struct GestureRecognizerConfiguration: Sendable { self.onMatch = onMatch self.recognitionContext = recognitionContext self.isRecognitionEnabled = isRecognitionEnabled + self.passesThroughEmptyMatcher = passesThroughEmptyMatcher self.areModifiersSatisfied = areModifiersSatisfied self.tuning = tuning } diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizerFailure.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizerFailure.swift index daa1f7e..507370c 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizerFailure.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizerFailure.swift @@ -1,11 +1,14 @@ /// Recognizer failures exposed by ``GestureRecognizer`` state. -public enum GestureRecognizerFailure: Hashable, Sendable { +public enum GestureRecognizerFailure: Equatable, Sendable { /// The event source failed to start. case eventSourceStartFailed + /// Recognition is disabled by host policy. case recognitionDisabled - /// Modifier keys did not satisfy host policy. + + /// Modifier flags did not satisfy host policy. case modifiersNotSatisfied - /// The active gesture session exceeded the configured duration. + + /// Pending button input or an active gesture session exceeded the configured duration. case gestureSessionExpired } diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizerState.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizerState.swift index a9f1265..a125026 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizerState.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizerState.swift @@ -1,17 +1,21 @@ /// Combined observable state for a gesture recognizer. -public struct GestureRecognizerState: Hashable, Sendable { +public struct GestureRecognizerState: Equatable, Sendable { /// Observable recognizer status. - public struct Status: Hashable, Sendable { + public struct Status: Equatable, Sendable { /// Event-source lifecycle exposed by ``GestureRecognizer`` state. - public enum Lifecycle: Hashable, Sendable { + public enum Lifecycle: Equatable, Sendable { /// The recognizer is stopped. case idle + /// The recognizer is starting its event source. case starting + /// The recognizer is ready to receive gesture input. case ready + /// The recognizer is waiting to retry event-source startup. case retrying + /// The recognizer failed to start and has no scheduled retry. case failed } @@ -22,15 +26,13 @@ public struct GestureRecognizerState: Hashable, Sendable { public let isCapturingGesture: Bool /// Whether recording mode is enabled. public let isRecordingModeEnabled: Bool - /// Directions recorded by the last completed gesture session. + /// Directions recorded by the last completed gesture session, including recording-mode + /// captures. public let lastRecordedDirections: [GestureDirection] - /// Last recognition failure. + /// Last startup, enablement-policy, modifier-policy, or input-expiration failure. public let lastFailure: GestureRecognizerFailure? /// Creates observable recognizer status. - /// - /// Omitted values describe an idle recognizer with no active gesture session, - /// recording mode disabled, no recorded directions, and no failure. public init( lifecycle: Lifecycle = .idle, isCapturingGesture: Bool = false, @@ -47,33 +49,31 @@ public struct GestureRecognizerState: Hashable, Sendable { } /// Observable trace state for a gesture recognizer. - public struct Trace: Hashable, Sendable { - /// Whether the trace is visible. + public struct Trace: Equatable, Sendable { + /// Whether the trace is visible after recording feedback or a valid matcher prefix. public let isVisible: Bool - /// Raw gesture points currently shown by the trace. + /// Published raw gesture point history. public let rawPoints: [GesturePoint] /// Gesture directions currently shown by the trace. public let directions: [GestureDirection] /// Normalized segment endpoints containing the trace start point plus one endpoint per direction. public let directionEndpoints: [GesturePoint] + /// Latest pointer point for low-latency live trace rendering. + public let tailPoint: GesturePoint? /// Creates observable trace state. - /// - /// Omitted values describe a hidden trace with no points or directions. public init( isVisible: Bool = false, rawPoints: [GesturePoint] = [], directions: [GestureDirection] = [], - directionEndpoints: [GesturePoint] = [] + directionEndpoints: [GesturePoint] = [], + tailPoint: GesturePoint? = nil ) { self.isVisible = isVisible self.rawPoints = rawPoints self.directions = directions - self.directionEndpoints = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: directionEndpoints, - recognizedDirectionCount: directions.count, - publishedRawPoints: rawPoints - ) + self.directionEndpoints = directionEndpoints + self.tailPoint = tailPoint } } @@ -83,8 +83,6 @@ public struct GestureRecognizerState: Hashable, Sendable { public let trace: Trace /// Creates combined observable recognizer state. - /// - /// Omitted values describe an idle recognizer with an empty trace. public init(status: Status = .init(), trace: Trace = .init()) { self.status = status self.trace = trace diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Timing.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizerTiming.swift similarity index 69% rename from Sources/PointerGestureKit/Recognizer/GestureRecognizer+Timing.swift rename to Sources/PointerGestureKit/Recognizer/GestureRecognizerTiming.swift index 82025a7..7d697cb 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizer+Timing.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizerTiming.swift @@ -1,9 +1,9 @@ import Foundation -private let nanosecondsPerSecond: TimeInterval = 1_000_000_000 - enum GestureRecognizerTiming { - static func sleepNanoseconds(for interval: TimeInterval) -> UInt64 { + private static let nanosecondsPerSecond: TimeInterval = 1_000_000_000 + + static func taskSleepNanoseconds(for interval: TimeInterval) -> UInt64 { guard interval > 0, interval.isFinite else { return 0 } let nanoseconds = interval * nanosecondsPerSecond diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizerTuning.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizerTuning.swift index 66a42cb..06f05ab 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizerTuning.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizerTuning.swift @@ -1,61 +1,94 @@ import Foundation -/// Tuning values for gesture recognition and event-source start retry. -public struct GestureRecognizerTuning: Hashable, Sendable { - /// Minimum horizontal or vertical movement needed before a gesture starts capturing directions. +/// Validated tuning values for gesture recognition and event-source start retry. +public struct GestureRecognizerTuning: Equatable, Sendable { + /// A reason custom tuning values could not be created. + public enum ValidationError: Error, Equatable, Sendable { + /// The gesture-start distance was negative or non-finite. + case invalidMinimumGestureStartAxisDistance + /// The direction-change distance was negative or non-finite. + case invalidMinimumDirectionChangeAxisDistance + /// The non-`nil` session duration was non-positive or non-finite. + case invalidMaximumGestureSessionDuration + /// The raw point count was not positive. + case invalidMaximumRawPointCount + /// A retry delay at the zero-based index was negative or non-finite. + case invalidEventSourceStartRetryDelay(index: Int) + } + + /// Minimum horizontal or vertical movement needed before a gesture starts. public let minimumGestureStartAxisDistance: Double /// Minimum horizontal or vertical movement needed before accepting a new direction. public let minimumDirectionChangeAxisDistance: Double - /// Maximum duration allowed for a gesture session. - /// - /// `nil` disables gesture-session expiration. + /// Maximum duration for pending or active gesture input, or `nil` to disable expiration. public let maximumGestureSessionDuration: TimeInterval? /// Maximum number of raw pointer points retained for an active gesture. public let maximumRawPointCount: Int - /// Delays used when retrying event-source start after start fails. - /// - /// An empty array disables scheduled retry. A zero delay retries on the next task yield. + /// Delays used in order when retrying event-source startup. public let eventSourceStartRetryDelays: [TimeInterval] - /// Creates tuning values, normalizing invalid numeric inputs at the package boundary. - /// - /// Non-finite or negative axis distances become zero. Non-positive or non-finite gesture session - /// durations become `nil`. Negative and non-finite retry delays are discarded; explicit zero - /// retry delays are preserved as next-yield retries. Empty retry delay arrays stay empty, and the - /// raw point count is clamped to at least one. - public init( - minimumGestureStartAxisDistance: Double = 10.0, - minimumDirectionChangeAxisDistance: Double = 25.0, - maximumGestureSessionDuration: TimeInterval? = 10.0, - maximumRawPointCount: Int = 1000, - eventSourceStartRetryDelays: [TimeInterval] = [0.25, 0.5, 1.0, 2.0, 4.0] - ) { - self.minimumGestureStartAxisDistance = Self.nonNegativeFiniteAxisDistance( - minimumGestureStartAxisDistance) - self.minimumDirectionChangeAxisDistance = Self.nonNegativeFiniteAxisDistance( - minimumDirectionChangeAxisDistance - ) - self.maximumGestureSessionDuration = maximumGestureSessionDuration.flatMap( - Self.positiveFiniteDuration - ) - self.maximumRawPointCount = max(1, maximumRawPointCount) - self.eventSourceStartRetryDelays = eventSourceStartRetryDelays.compactMap( - Self.nonNegativeFiniteDelay - ) - } + /// Recommended production tuning. + public static let standard = Self( + minimumGestureStartAxisDistance: 10, + minimumDirectionChangeAxisDistance: 25, + maximumGestureSessionDuration: 1, + maximumRawPointCount: 256, + eventSourceStartRetryDelays: [0.25, 0.5, 1, 2, 4] + ) - private static func nonNegativeFiniteAxisDistance(_ value: Double) -> Double { - guard value.isFinite else { return 0 } - return max(0, value) - } + /// Creates custom tuning after validating every value. + /// + /// Distances and retry delays must be finite and nonnegative. A non-`nil` session duration must + /// be finite and positive. The raw point count must be positive. Retry order and duplicates are + /// preserved, and an explicit zero delay means retry on the next task yield. + public static func validated( + minimumGestureStartAxisDistance: Double = 10, + minimumDirectionChangeAxisDistance: Double = 25, + maximumGestureSessionDuration: TimeInterval? = 1, + maximumRawPointCount: Int = 256, + eventSourceStartRetryDelays: [TimeInterval] = [0.25, 0.5, 1, 2, 4] + ) throws -> Self { + guard minimumGestureStartAxisDistance.isFinite, minimumGestureStartAxisDistance >= 0 else { + throw ValidationError.invalidMinimumGestureStartAxisDistance + } + guard minimumDirectionChangeAxisDistance.isFinite, minimumDirectionChangeAxisDistance >= 0 + else { + throw ValidationError.invalidMinimumDirectionChangeAxisDistance + } + if let maximumGestureSessionDuration { + guard maximumGestureSessionDuration.isFinite, maximumGestureSessionDuration > 0 else { + throw ValidationError.invalidMaximumGestureSessionDuration + } + } + guard maximumRawPointCount > 0 else { + throw ValidationError.invalidMaximumRawPointCount + } + for (index, delay) in eventSourceStartRetryDelays.enumerated() { + guard delay.isFinite, delay >= 0 else { + throw ValidationError.invalidEventSourceStartRetryDelay(index: index) + } + } - private static func positiveFiniteDuration(_ value: TimeInterval) -> TimeInterval? { - guard value.isFinite, value > 0 else { return nil } - return value + return Self( + minimumGestureStartAxisDistance: minimumGestureStartAxisDistance, + minimumDirectionChangeAxisDistance: minimumDirectionChangeAxisDistance, + maximumGestureSessionDuration: maximumGestureSessionDuration, + maximumRawPointCount: maximumRawPointCount, + eventSourceStartRetryDelays: eventSourceStartRetryDelays + ) } - private static func nonNegativeFiniteDelay(_ value: TimeInterval) -> TimeInterval? { - guard value.isFinite, value >= 0 else { return nil } - return value + private init( + minimumGestureStartAxisDistance: Double, + minimumDirectionChangeAxisDistance: Double, + maximumGestureSessionDuration: TimeInterval?, + maximumRawPointCount: Int, + eventSourceStartRetryDelays: [TimeInterval] + ) { + self.minimumGestureStartAxisDistance = minimumGestureStartAxisDistance + self.minimumDirectionChangeAxisDistance = minimumDirectionChangeAxisDistance + self.maximumGestureSessionDuration = maximumGestureSessionDuration + self.maximumRawPointCount = maximumRawPointCount + self.eventSourceStartRetryDelays = eventSourceStartRetryDelays } } diff --git a/Sources/PointerGestureKit/Recognizer/GestureTraceEndpointNormalizer.swift b/Sources/PointerGestureKit/Recognizer/GestureTraceEndpointNormalizer.swift deleted file mode 100644 index 72aa4c1..0000000 --- a/Sources/PointerGestureKit/Recognizer/GestureTraceEndpointNormalizer.swift +++ /dev/null @@ -1,91 +0,0 @@ -/// Normalizes gesture trace endpoints for observation. -enum GestureTraceEndpointNormalizer { - /// Returns endpoints sized to the number of recognized directions when points are available. - static func normalizedEndpoints( - endpoints: [GesturePoint], - recognizedDirectionCount: Int, - publishedRawPoints: [GesturePoint] - ) -> [GesturePoint] { - guard recognizedDirectionCount >= 0 else { return [] } - guard recognizedDirectionCount < Int.max else { return endpoints } - - let expectedEndpointCount = recognizedDirectionCount + 1 - let trimmedPoints = trimmed( - endpoints, - toExpectedEndpointCount: expectedEndpointCount - ) - return padded( - trimmedPoints, - toExpectedEndpointCount: expectedEndpointCount, - publishedRawPoints: publishedRawPoints - ) - } - - private static func trimmed( - _ endpoints: [GesturePoint], - toExpectedEndpointCount expectedEndpointCount: Int - ) -> [GesturePoint] { - guard endpoints.count > expectedEndpointCount else { return endpoints } - guard expectedEndpointCount > 1 else { - return Array(endpoints.prefix(expectedEndpointCount)) - } - - var trimmed = Array(endpoints.prefix(expectedEndpointCount - 1)) - if let last = endpoints.last { - trimmed.append(last) - } - return trimmed - } - - private static func padded( - _ endpoints: [GesturePoint], - toExpectedEndpointCount expectedEndpointCount: Int, - publishedRawPoints: [GesturePoint] - ) -> [GesturePoint] { - var padded = endpoints - guard padded.count < expectedEndpointCount else { return padded } - - let publishedRawPointCandidates = remainingRawPoints( - afterMatching: padded, - in: publishedRawPoints - ) - var iterator = publishedRawPointCandidates.makeIterator() - while padded.count < expectedEndpointCount, let next = iterator.next() { - if let last = padded.last, last == next { - continue - } - padded.append(next) - } - - guard padded.count < expectedEndpointCount, - let fillerPoint = padded.last ?? publishedRawPoints.last - else { - return padded - } - - padded.append( - contentsOf: Array( - repeating: fillerPoint, - count: expectedEndpointCount - padded.count - )) - - return padded - } - - private static func remainingRawPoints( - afterMatching endpoints: [GesturePoint], - in publishedRawPoints: [GesturePoint] - ) -> ArraySlice { - var searchStartIndex = publishedRawPoints.startIndex - - for point in endpoints { - guard let matchedIndex = publishedRawPoints[searchStartIndex...].firstIndex(of: point) else { - return publishedRawPoints[searchStartIndex...] - } - - searchStartIndex = publishedRawPoints.index(after: matchedIndex) - } - - return publishedRawPoints[searchStartIndex...] - } -} diff --git a/Sources/PointerGestureKitCoreGraphics/CoreGraphics/PointerButton+CoreGraphics.swift b/Sources/PointerGestureKitCoreGraphics/CoreGraphics/PointerButton+CoreGraphics.swift index a3f694d..ad5dc54 100644 --- a/Sources/PointerGestureKitCoreGraphics/CoreGraphics/PointerButton+CoreGraphics.swift +++ b/Sources/PointerGestureKitCoreGraphics/CoreGraphics/PointerButton+CoreGraphics.swift @@ -13,7 +13,7 @@ extension PointerButton { self = .middle default: guard cgButtonNumber > 2, cgButtonNumber <= Int64(UInt32.max) else { return nil } - self.init(additionalButtonNumber: UInt32(cgButtonNumber)) + self.init(auxiliaryButtonID: UInt32(cgButtonNumber)) } } @@ -21,39 +21,35 @@ extension PointerButton { if self == .primary { return .left } if self == .secondary { return .right } if self == .middle { return .center } - guard let additionalButtonNumber else { return nil } - return CGMouseButton(rawValue: additionalButtonNumber) - } - - var usesOtherMouseEventTypes: Bool { - self == .middle || additionalButtonNumber != nil + guard let auxiliaryButtonID else { return nil } + return CGMouseButton(rawValue: auxiliaryButtonID) } var cgEventTypes: PointerButtonCGEventTypes { if self == .primary { return PointerButtonCGEventTypes( down: .leftMouseDown, - dragged: .leftMouseDragged, + moved: .leftMouseDragged, up: .leftMouseUp ) } if self == .secondary { return PointerButtonCGEventTypes( down: .rightMouseDown, - dragged: .rightMouseDragged, + moved: .rightMouseDragged, up: .rightMouseUp ) } return PointerButtonCGEventTypes( down: .otherMouseDown, - dragged: .otherMouseDragged, + moved: .otherMouseDragged, up: .otherMouseUp ) } } -struct PointerButtonCGEventTypes: Equatable, Sendable { +struct PointerButtonCGEventTypes: Sendable { let down: CGEventType - let dragged: CGEventType + let moved: CGEventType let up: CGEventType } diff --git a/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap+EventMapping.swift b/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap+EventMapping.swift index 01086f5..9958da5 100644 --- a/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap+EventMapping.swift +++ b/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap+EventMapping.swift @@ -1,12 +1,11 @@ import class CoreGraphics.CGEvent import struct CoreGraphics.CGEventFlags import enum CoreGraphics.CGEventField -import typealias CoreGraphics.CGEventMask import enum CoreGraphics.CGEventType import PointerGestureKit extension GestureEventTap { - nonisolated var eventMask: CGEventMask { + nonisolated var eventMask: UInt64 { guard !capturedButtons.isEmpty else { return 0 } var eventTypes = Set() @@ -14,16 +13,12 @@ extension GestureEventTap { for button in capturedButtons { let buttonEventTypes = button.cgEventTypes eventTypes.insert(buttonEventTypes.down) - eventTypes.insert(buttonEventTypes.dragged) + eventTypes.insert(buttonEventTypes.moved) eventTypes.insert(buttonEventTypes.up) } return eventTapMask(for: eventTypes) } - nonisolated func inputEvent(for type: CGEventType, event: CGEvent) -> GestureInputEvent? { - mappedInputEvent(for: type, event: event)?.input - } - nonisolated func mappedInputEvent(for type: CGEventType, event: CGEvent) -> MappedInputEvent? { guard !capturedButtons.isEmpty else { return nil } @@ -46,6 +41,14 @@ extension GestureEventTap { ) } + nonisolated func mappedTapDisabledInputEvent( + for type: CGEventType, + event: CGEvent + ) -> MappedInputEvent? { + guard type == .tapDisabledByTimeout || type == .tapDisabledByUserInput else { return nil } + return makeMappedInputEvent(kind: .cancel, button: nil, type: type, event: event) + } + private nonisolated func makeMappedInputEvent( kind: GestureInputEvent.Kind, button: PointerButton?, @@ -79,7 +82,7 @@ private struct PointerButtonInputMapping { private let escapeVirtualKeyCode: Int64 = 53 -private func eventTapMask(for types: some Sequence) -> CGEventMask { +private func eventTapMask(for types: some Sequence) -> UInt64 { types.reduce(0) { mask, type in mask | (1 << type.rawValue) } @@ -93,13 +96,13 @@ private func pointerButtonInput( case .leftMouseDown: return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonDown, button: .primary) case .leftMouseDragged: - return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonDragged, button: .primary) + return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonMoved, button: .primary) case .leftMouseUp: return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonUp, button: .primary) case .rightMouseDown: return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonDown, button: .secondary) case .rightMouseDragged: - return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonDragged, button: .secondary) + return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonMoved, button: .secondary) case .rightMouseUp: return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonUp, button: .secondary) case .otherMouseDown: @@ -107,7 +110,7 @@ private func pointerButtonInput( return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonDown, button: button) case .otherMouseDragged: guard let button = otherPointerButton(for: event) else { return nil } - return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonDragged, button: button) + return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonMoved, button: button) case .otherMouseUp: guard let button = otherPointerButton(for: event) else { return nil } return PointerButtonInputMapping(kind: GestureInputEvent.Kind.buttonUp, button: button) @@ -117,7 +120,8 @@ private func pointerButtonInput( } private func otherPointerButton(for event: CGEvent) -> PointerButton? { - guard let button = pointerButton(for: event), button.usesOtherMouseEventTypes else { return nil } + guard let button = pointerButton(for: event) else { return nil } + guard button == .middle || button.auxiliaryButtonID != nil else { return nil } return button } diff --git a/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap+Replay.swift b/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap+Replay.swift index 2146326..10f1152 100644 --- a/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap+Replay.swift +++ b/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap+Replay.swift @@ -6,14 +6,26 @@ import enum CoreGraphics.CGEventTapLocation import enum CoreGraphics.CGEventType import PointerGestureKit +private let maximumReplayDragPointCount = 64 + extension GestureEventTap { /// Replays the concrete Core Graphics events for a recognizer replay request. + /// + /// Click requests post button-down and button-up events. Drag requests post down, move, and up + /// events; empty drag requests are ignored and single-point drag requests replay as a click. + /// Drag-start requests post down and move events without a release. Release requests post only a + /// button-up event. + /// + /// Replayed events are marked so this tap can ignore them if Core Graphics reports them back + /// through the event stream. public func replay(_ request: GestureReplayRequest) { switch request { case let .click(button, location): replayClick(button: button, at: location) case let .drag(button, points): replayDrag(button: button, points: points) + case let .dragStart(button, points): + replayDragStart(button: button, points: points) case let .release(button, location): replayRelease(button: button, at: location) } @@ -67,9 +79,16 @@ extension GestureEventTap { } /// Replays a consumed pointer-button down, drag, and release sequence. + /// + /// Empty drag requests do nothing. A single-point drag request is replayed as a click at that + /// point. private func replayDrag(button: PointerButton, points: [GesturePoint]) { - guard let firstPoint = points.first else { return } - guard points.count > 1 else { + let replayPoints = coalescedReplayDragPoints( + points, + maximumPointCount: maximumReplayDragPointCount + ) + guard let firstPoint = replayPoints.first else { return } + guard replayPoints.count > 1 else { replayClick(button: button, at: firstPoint) return } @@ -86,24 +105,93 @@ extension GestureEventTap { else { return } guard let up = replayedButtonEvent( - type: eventTypes.up, button: button, at: points[points.count - 1], source: source) + type: eventTypes.up, + button: button, + at: replayPoints[replayPoints.count - 1], + source: source + ) else { return } guard let dragEvents = replayedButtonEvents( - type: eventTypes.dragged, button: button, at: points.dropFirst(), source: source) + type: eventTypes.moved, + button: button, + at: replayPoints.dropFirst(), + source: source + ) else { return } postReplayedButtonEvents([down] + dragEvents + [up]) } + /// Replays the consumed start of a pointer-button drag and leaves release to the real event stream. + /// + /// Empty drag-start requests do nothing. A single-point drag-start request posts only a + /// button-down event at that point. + private func replayDragStart(button: PointerButton, points: [GesturePoint]) { + let replayPoints = coalescedReplayDragPoints( + points, + maximumPointCount: maximumReplayDragPointCount + ) + guard let firstPoint = replayPoints.first else { return } + + let source = replayEventSource() + let eventTypes = button.cgEventTypes + guard + let down = replayedButtonEvent( + type: eventTypes.down, + button: button, + at: firstPoint, + source: source + ) + else { return } + + guard replayPoints.count > 1 else { + postReplayedButtonEvents([down]) + return + } + + guard + let dragEvents = replayedButtonEvents( + type: eventTypes.moved, + button: button, + at: replayPoints.dropFirst(), + source: source + ) + else { return } + + postReplayedButtonEvents([down] + dragEvents) + } + private func postReplayedButtonEvents(_ events: [ReplayedButtonEvent]) { suppressNextSyntheticEventSignatures(events.map(\.signature)) for event in events { event.event.post(tap: .cghidEventTap) } } +} + +func coalescedReplayDragPoints( + _ points: [GesturePoint], + maximumPointCount: Int +) -> [GesturePoint] { + let maximumPointCount = max(2, maximumPointCount) + guard points.count > maximumPointCount else { return points } + + let lastIndex = points.count - 1 + let step = Double(lastIndex) / Double(maximumPointCount - 1) + var coalesced: [GesturePoint] = [] + coalesced.reserveCapacity(maximumPointCount) + + for outputIndex in 0.. ReplayedButtonEvent? { + guard location.isFinite else { return nil } guard let cgMouseButton = button.cgMouseButton else { return nil } guard let event = CGEvent( diff --git a/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap.swift b/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap.swift index aec5b63..bc1c71e 100644 --- a/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap.swift +++ b/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap.swift @@ -10,8 +10,8 @@ import PointerGestureKit @MainActor public final class GestureEventTap: GestureEventSource { let capturedButtons: Set - private var tapPort: EventTapPort? - private var runLoopSource: EventTapRunLoopSource? + private var tapPort: CFMachPort? + private var runLoopSource: CFRunLoopSource? private var eventHandler: (@MainActor @Sendable (GestureInputEvent) -> GestureEventDisposition)? var syntheticEventSuppression = SyntheticEventSuppression() @@ -22,27 +22,14 @@ public final class GestureEventTap: GestureEventSource { self.capturedButtons = capturedButtons } - deinit { - let cleanup = GestureEventTapDeinitCleanup( - tapPort: tapPort, - runLoopSource: runLoopSource - ) - - if Thread.isMainThread { - MainActor.assumeIsolated { - cleanup.run() - } - } else { - Task { @MainActor in - cleanup.run() - } - } + isolated deinit { + stop() } /// Starts the macOS event tap. /// /// A successful start replaces any previously active event tap and handler. - /// Returns `false` when no pointer buttons are configured, or when CoreGraphics cannot create or + /// Returns `false` when no pointer buttons are configured, or when Core Graphics cannot create or /// install the event tap, for example when the process is missing the required Accessibility /// permission. public func start( @@ -65,12 +52,12 @@ public final class GestureEventTap: GestureEventSource { /// Calling this while the tap is already stopped is safe. public func stop() { if let source = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetMain(), source.value, .commonModes) + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) runLoopSource = nil } if let port = tapPort { - CGEvent.tapEnable(tap: port.value, enable: false) - CFMachPortInvalidate(port.value) + CGEvent.tapEnable(tap: port, enable: false) + CFMachPortInvalidate(port) tapPort = nil } eventHandler = nil @@ -79,7 +66,7 @@ public final class GestureEventTap: GestureEventSource { private func reenableTapPort() { guard let port = tapPort else { return } - CGEvent.tapEnable(tap: port.value, enable: true) + CGEvent.tapEnable(tap: port, enable: true) } private func makeTapPort() -> CFMachPort? { @@ -104,8 +91,8 @@ public final class GestureEventTap: GestureEventSource { ) { eventHandler = handler CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) - tapPort = EventTapPort(port) - runLoopSource = EventTapRunLoopSource(source) + tapPort = port + runLoopSource = source CGEvent.tapEnable(tap: port, enable: true) } @@ -123,9 +110,23 @@ public final class GestureEventTap: GestureEventSource { type: CGEventType, event: CGEvent ) -> Unmanaged? { - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let mappedTapDisabledInput = mappedTapDisabledInputEvent(for: type, event: event) { + if Thread.isMainThread { + MainActor.assumeIsolated { + recoverFromTapDisabled( + input: mappedTapDisabledInput.input, + sourceSignature: mappedTapDisabledInput.sourceSignature + ) + } + return Unmanaged.passUnretained(event) + } + Task { @MainActor [weak self] in - self?.reenableTapPort() + guard let self else { return } + recoverFromTapDisabled( + input: mappedTapDisabledInput.input, + sourceSignature: mappedTapDisabledInput.sourceSignature + ) } return Unmanaged.passUnretained(event) } @@ -139,7 +140,10 @@ public final class GestureEventTap: GestureEventSource { let disposition = MainActor.assumeIsolated { dispatchInputEvent(mappedInputEvent.input, sourceSignature: mappedInputEvent.sourceSignature) } - return disposition == .consume ? nil : Unmanaged.passUnretained(event) + if disposition.consumesOriginalEvent { + return nil + } + return Unmanaged.passUnretained(event) } private func dispatchInputEvent( @@ -154,36 +158,13 @@ public final class GestureEventTap: GestureEventSource { return gestureHandler(input) } -} - -private struct GestureEventTapDeinitCleanup: @unchecked Sendable { - let tapPort: EventTapPort? - let runLoopSource: EventTapRunLoopSource? - - @MainActor - func run() { - if let runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource.value, .commonModes) - } - if let tapPort { - CGEvent.tapEnable(tap: tapPort.value, enable: false) - CFMachPortInvalidate(tapPort.value) - } - } -} - -private struct EventTapPort: @unchecked Sendable { - let value: CFMachPort - init(_ value: CFMachPort) { - self.value = value - } -} - -private struct EventTapRunLoopSource: @unchecked Sendable { - let value: CFRunLoopSource - - init(_ value: CFRunLoopSource) { - self.value = value + func recoverFromTapDisabled( + input: GestureInputEvent, + sourceSignature: SyntheticEventSignature + ) { + syntheticEventSuppression.removeAll() + _ = dispatchInputEvent(input, sourceSignature: sourceSignature) + reenableTapPort() } } diff --git a/Sources/PointerGestureKitCoreGraphics/EventTap/SyntheticEventSuppression.swift b/Sources/PointerGestureKitCoreGraphics/EventTap/SyntheticEventSuppression.swift index 8ff5b92..45f2e2e 100644 --- a/Sources/PointerGestureKitCoreGraphics/EventTap/SyntheticEventSuppression.swift +++ b/Sources/PointerGestureKitCoreGraphics/EventTap/SyntheticEventSuppression.swift @@ -9,13 +9,13 @@ extension GestureEventTap { } func consumePendingSyntheticEventSignature(_ signature: SyntheticEventSignature) -> Bool { - syntheticEventSuppression.consume(signature) + syntheticEventSuppression.consumeThrough(signature) } } struct SyntheticEventSignature: Equatable, Sendable { - let type: CGEventType - let button: PointerButton? + private let type: CGEventType + private let button: PointerButton? private let eventSourceUserData: Int64 private init(type: CGEventType, button: PointerButton?, eventSourceUserData: Int64) { @@ -50,21 +50,49 @@ struct SyntheticEventSignature: Equatable, Sendable { eventSourceUserData: eventSourceUserData ) } + + static var tapDisabledTimeout: Self { + Self(type: .tapDisabledByTimeout, button: nil, eventSourceUserData: 0) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.type == rhs.type + && lhs.button == rhs.button + && lhs.eventSourceUserData == rhs.eventSourceUserData + } + + var isReplayedEvent: Bool { + eventSourceUserData == replayEventSourceUserData + } } private let replayEventSourceUserData: Int64 = 0x5047_4B54 struct SyntheticEventSuppression { + private let maximumPendingSignatureCount: Int private var pendingSignatures: [SyntheticEventSignature] = [] + init(maximumPendingSignatureCount: Int = 512) { + self.maximumPendingSignatureCount = max(1, maximumPendingSignatureCount) + } + + /// Queues replayed event signatures that should be ignored when observed through the tap. mutating func suppress(_ signatures: [SyntheticEventSignature]) { pendingSignatures.append(contentsOf: signatures) + trimPendingSignatures() } - mutating func consume(_ signature: SyntheticEventSignature) -> Bool { - guard let signatureIndex = pendingSignatures.firstIndex(of: signature) else { + /// Removes the matching replayed event signature and any earlier pending signatures. + /// + /// The tap may not observe every replayed event. When a later replayed signature arrives, earlier + /// pending signatures can no longer be consumed and are discarded. + mutating func consumeThrough(_ signature: SyntheticEventSignature) -> Bool { + guard signature.isReplayedEvent else { return false } + guard let signatureIndex = pendingSignatures.firstIndex(of: signature) else { + return true + } pendingSignatures.removeFirst(signatureIndex + 1) return true } @@ -72,4 +100,10 @@ struct SyntheticEventSuppression { mutating func removeAll() { pendingSignatures.removeAll() } + + private mutating func trimPendingSignatures() { + let overflow = pendingSignatures.count - maximumPendingSignatureCount + guard overflow > 0 else { return } + pendingSignatures.removeFirst(overflow) + } } diff --git a/Sources/PointerGestureKitCoreGraphics/PointerGestureKitCoreGraphics.docc/PointerGestureKitCoreGraphics.md b/Sources/PointerGestureKitCoreGraphics/PointerGestureKitCoreGraphics.docc/PointerGestureKitCoreGraphics.md index ce9a6a1..02a25c7 100644 --- a/Sources/PointerGestureKitCoreGraphics/PointerGestureKitCoreGraphics.docc/PointerGestureKitCoreGraphics.md +++ b/Sources/PointerGestureKitCoreGraphics/PointerGestureKitCoreGraphics.docc/PointerGestureKitCoreGraphics.md @@ -7,74 +7,52 @@ Capture macOS pointer-button drag input with a Core Graphics event tap. PointerGestureKitCoreGraphics provides the live macOS adapter for `PointerGestureKit`. Use ``GestureEventTap`` when a macOS app needs to capture pointer-button drag gestures with a Core Graphics session event tap. It defaults to the secondary button and can be configured for primary, -middle, or additional pointer buttons. Passing an empty captured-button set captures no input and +middle, or auxiliary pointer buttons. Passing an empty captured-button set captures no input and ``GestureEventTap/start(handler:)`` returns `false`. When a recognizer uses a non-default recognition button, create ``GestureEventTap`` with a `capturedButtons` set that includes that same button. -The adapter maps configured mouse-button down, dragged, up, and Escape key-down events to +The adapter maps configured mouse-button down, drag-move, up, and Escape key-down events to platform-neutral `PointerGestureKit.GestureInputEvent` values. Primary buttons use left-mouse -events, secondary buttons use right-mouse events, and middle or additional buttons use other-mouse +events, secondary buttons use right-mouse events, and middle or auxiliary buttons use other-mouse events with the Core Graphics button number. ## Usage -Create one event tap and pass it both as the recognizer event source and as the replay handler: +Create one event tap at the live system boundary, then bridge Core Graphics points into +`GesturePoint` values when replaying concrete pointer events: ```swift +import CoreGraphics import PointerGestureKit import PointerGestureKitCoreGraphics -enum AppCommand: Sendable { - case showInspector -} - -@MainActor -func makeMatcher() -> GesturePatternMatcher { - var matcher = GesturePatternMatcher() - matcher.register(pattern: [.down, .right], match: .showInspector) - return matcher -} - @MainActor -func run(_: AppCommand) { +func makeEventTap() -> GestureEventTap { + let eventTap = GestureEventTap(capturedButtons: [.secondary]) + let replayPoint = GesturePoint(CGPoint(x: 120, y: 80)) + let originalPoint = replayPoint.cgPoint + + eventTap.replay(.click(button: .secondary, at: replayPoint)) + _ = originalPoint + return eventTap } - -@MainActor -let eventTap = GestureEventTap() - -@MainActor -let configuration = GestureRecognizerConfiguration( - makeMatcher: { _ in makeMatcher() }, - onReplayRequested: eventTap.replay, - onMatch: { command in - run(command) - } -) - -@MainActor -let recognizer = GestureRecognizer( - eventSource: eventTap, - configuration: configuration -) - -recognizer.start() ``` -For a non-default recognition button, configure the adapter and recognizer with the same button: +For recognizer wiring, pass the same event tap as the recognizer event source and replay handler. +For a non-default recognition button, configure the adapter and recognizer with the same button, +such as `.primary`. + +Convert Core Graphics points at the adapter boundary when a host app needs to +move between live `CGPoint` values and neutral gesture coordinates: ```swift -let eventTap = GestureEventTap(capturedButtons: [.primary]) - -let configuration = GestureRecognizerConfiguration( - makeMatcher: { _ in makeMatcher() }, - onReplayRequested: eventTap.replay, - onMatch: { command in - run(command) - }, - recognitionButton: .primary -) +import CoreGraphics +import PointerGestureKit +import PointerGestureKitCoreGraphics + +let point = GesturePoint(CGPoint(x: 10, y: 20)) ``` ## Responsibility Boundary @@ -83,27 +61,33 @@ PointerGestureKitCoreGraphics owns the live Core Graphics event-tap adapter for drag input, Escape-key cancellation, modifier mapping, and pointer-button replay adaptation. Use ``GestureEventTap/replay(_:)`` to handle click, drag, and release replay requests emitted by the recognizer. A click replay posts button-down and button-up events before -returning. A drag replay posts down, dragged points, and up; empty drag requests are ignored. +returning. A drag replay posts down, move, and up events; empty drag requests are ignored and +single-point drag requests replay as a click at that point. Replayed events are marked so the tap can +ignore them if Core Graphics reports them back through the event stream. Keep command execution, trace UI, Accessibility permission presentation, privacy policy, persistence, telemetry, analytics, and product behavior in the host app. This target translates supported `CGEvent` values into `PointerGestureKit.GestureInputEvent` values, applies the recognizer's event-disposition decision, and replays concrete Core Graphics events requested by `GestureReplayRequest` values. It reports event-tap creation failure through -``GestureEventTap/start(handler:)``; host apps remain responsible for Accessibility permission -onboarding and recovery UI. +``GestureEventTap/start(handler:)``. Host applications are responsible for Accessibility +permission onboarding and recovery UI. +The ``PointerGestureKit/GesturePoint/init(_:)`` and ``PointerGestureKit/GesturePoint/cgPoint`` +bridges preserve coordinates and expose the core ``PointerGestureKit/GesturePoint/isFinite`` check +for non-finite input. They do not flip coordinate axes; platform-specific +screen or view coordinate transforms belong in the host app. ## Topics ### Event Tap - ``GestureEventTap`` - -### Replay - +- ``GestureEventTap/init(capturedButtons:)`` +- ``GestureEventTap/start(handler:)`` +- ``GestureEventTap/stop()`` - ``GestureEventTap/replay(_:)`` -### Core Graphics Conversion +### Core Graphics Bridges - ``PointerGestureKit/GesturePoint/init(_:)`` - ``PointerGestureKit/GesturePoint/cgPoint`` diff --git a/Tests/PointerGestureKitCoreGraphicsTests/CoreGraphics/GesturePointConversionTests.swift b/Tests/PointerGestureKitCoreGraphicsTests/CoreGraphics/GesturePointConversionTests.swift index ad1334a..8f74a23 100644 --- a/Tests/PointerGestureKitCoreGraphicsTests/CoreGraphics/GesturePointConversionTests.swift +++ b/Tests/PointerGestureKitCoreGraphicsTests/CoreGraphics/GesturePointConversionTests.swift @@ -24,23 +24,11 @@ final class GesturePointConversionTests: XCTestCase { } func testCGPointInitializerUsesGesturePointCoordinateNormalization() { - XCTAssertEqual( - GesturePoint(CGPoint(x: CGFloat.nan, y: 18.5)), - GesturePoint(x: 0, y: 18.5) - ) - XCTAssertEqual( - GesturePoint(CGPoint(x: -7.25, y: CGFloat.infinity)), - GesturePoint(x: -7.25, y: 0) - ) + XCTAssertFalse(GesturePoint(CGPoint(x: CGFloat.nan, y: 18.5)).isFinite) + XCTAssertFalse(GesturePoint(CGPoint(x: -7.25, y: CGFloat.infinity)).isFinite) } - func testCGPointPropertyUsesNormalizedGesturePointCoordinates() { - let point = GesturePoint(x: Double.nan, y: Double.infinity) - - XCTAssertEqual(point.cgPoint, CGPoint.zero) - } - - func testRoundTripPreservesFiniteCoordinates() { + func testFiniteCoordinatesRoundTripThroughCGPoint() { let point = GesturePoint(x: -3.25, y: 9.75) XCTAssertEqual(GesturePoint(point.cgPoint), point) diff --git a/Tests/PointerGestureKitCoreGraphicsTests/CoreGraphics/PointerButtonConversionTests.swift b/Tests/PointerGestureKitCoreGraphicsTests/CoreGraphics/PointerButtonConversionTests.swift index 363a4bd..5de83e9 100644 --- a/Tests/PointerGestureKitCoreGraphicsTests/CoreGraphics/PointerButtonConversionTests.swift +++ b/Tests/PointerGestureKitCoreGraphicsTests/CoreGraphics/PointerButtonConversionTests.swift @@ -2,7 +2,6 @@ import enum CoreGraphics.CGEventType import enum CoreGraphics.CGMouseButton import PointerGestureKit import XCTest - @testable import PointerGestureKitCoreGraphics final class PointerButtonConversionTests: XCTestCase { @@ -12,76 +11,60 @@ final class PointerButtonConversionTests: XCTestCase { XCTAssertEqual(PointerButton(cgButtonNumber: 2), .middle) XCTAssertEqual( PointerButton(cgButtonNumber: 3), - PointerButton(additionalButtonNumber: 3) - ) - XCTAssertEqual( - PointerButton(cgButtonNumber: Int64(UInt32.max)), - PointerButton(additionalButtonNumber: UInt32.max) + PointerButton(auxiliaryButtonID: 3) ) - } - func testPointerButtonRejectsInvalidCoreGraphicsButtonNumbers() { XCTAssertNil(PointerButton(cgButtonNumber: -1)) XCTAssertNil(PointerButton(cgButtonNumber: Int64(UInt32.max) + 1)) } - func testCoreGraphicsMouseButtonMapsPointerButtons() throws { - let additionalButton = try XCTUnwrap(PointerButton(additionalButtonNumber: 3)) + func testPointerButtonMapsCoreGraphicsMouseButtonsForReplay() throws { + let auxiliaryButton = try XCTUnwrap(PointerButton(auxiliaryButtonID: 4)) XCTAssertEqual(PointerButton.primary.cgMouseButton, .left) XCTAssertEqual(PointerButton.secondary.cgMouseButton, .right) XCTAssertEqual(PointerButton.middle.cgMouseButton, .center) - XCTAssertEqual(additionalButton.cgMouseButton, CGMouseButton(rawValue: 3)) + XCTAssertEqual(auxiliaryButton.cgMouseButton, CGMouseButton(rawValue: 4)) } - func testPointerButtonClassifiesCoreGraphicsEventFamilies() throws { - let additionalButton = try XCTUnwrap(PointerButton(additionalButtonNumber: 4)) - - XCTAssertFalse(PointerButton.primary.usesOtherMouseEventTypes) - XCTAssertFalse(PointerButton.secondary.usesOtherMouseEventTypes) - XCTAssertTrue(PointerButton.middle.usesOtherMouseEventTypes) - XCTAssertTrue(additionalButton.usesOtherMouseEventTypes) - } - - func testPointerButtonsExposeCoreGraphicsEventFamilies() { - XCTAssertEqual( + func testPointerButtonMapsCoreGraphicsEventFamiliesForCaptureAndReplay() throws { + assertEventTypes( PointerButton.primary.cgEventTypes, - PointerButtonCGEventTypes( - down: .leftMouseDown, - dragged: .leftMouseDragged, - up: .leftMouseUp - ) + down: .leftMouseDown, + moved: .leftMouseDragged, + up: .leftMouseUp ) - - XCTAssertEqual( + assertEventTypes( PointerButton.secondary.cgEventTypes, - PointerButtonCGEventTypes( - down: .rightMouseDown, - dragged: .rightMouseDragged, - up: .rightMouseUp - ) + down: .rightMouseDown, + moved: .rightMouseDragged, + up: .rightMouseUp ) - - XCTAssertEqual( + assertEventTypes( PointerButton.middle.cgEventTypes, - PointerButtonCGEventTypes( - down: .otherMouseDown, - dragged: .otherMouseDragged, - up: .otherMouseUp - ) + down: .otherMouseDown, + moved: .otherMouseDragged, + up: .otherMouseUp ) - } - - func testAdditionalPointerButtonMapsOtherMouseEventTypes() throws { - let button = try XCTUnwrap(PointerButton(additionalButtonNumber: 4)) - - XCTAssertEqual( - button.cgEventTypes, - PointerButtonCGEventTypes( - down: .otherMouseDown, - dragged: .otherMouseDragged, - up: .otherMouseUp - ) + let auxiliaryButton = try XCTUnwrap(PointerButton(auxiliaryButtonID: 4)) + assertEventTypes( + auxiliaryButton.cgEventTypes, + down: .otherMouseDown, + moved: .otherMouseDragged, + up: .otherMouseUp ) } } + +private func assertEventTypes( + _ eventTypes: PointerButtonCGEventTypes, + down: CGEventType, + moved: CGEventType, + up: CGEventType, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertEqual(eventTypes.down, down, file: file, line: line) + XCTAssertEqual(eventTypes.moved, moved, file: file, line: line) + XCTAssertEqual(eventTypes.up, up, file: file, line: line) +} diff --git a/Tests/PointerGestureKitCoreGraphicsTests/EventTap/GestureEventTapEventMappingTests.swift b/Tests/PointerGestureKitCoreGraphicsTests/EventTap/GestureEventTapEventMappingTests.swift index bf2c259..2407541 100644 --- a/Tests/PointerGestureKitCoreGraphicsTests/EventTap/GestureEventTapEventMappingTests.swift +++ b/Tests/PointerGestureKitCoreGraphicsTests/EventTap/GestureEventTapEventMappingTests.swift @@ -1,7 +1,5 @@ import class CoreGraphics.CGEvent import struct CoreGraphics.CGPoint -import typealias CoreGraphics.CGEventMask -import typealias CoreGraphics.CGKeyCode import enum CoreGraphics.CGEventType import enum CoreGraphics.CGMouseButton import PointerGestureKit @@ -46,9 +44,9 @@ final class GestureEventTapEventMappingTests: XCTestCase { ) event.flags = [.maskCommand, .maskShift] - let input = tap.inputEvent(for: .rightMouseDown, event: event) + let input = inputEvent(from: tap, for: .rightMouseDown, event: event) - XCTAssertEqual(input?.kind, .buttonDown(.secondary)) + assertInputKind(input?.kind, .buttonDown(.secondary)) XCTAssertEqual(input?.location, GesturePoint(x: 12, y: 34)) XCTAssertEqual(input?.modifiers, [.command, .shift]) } @@ -61,61 +59,29 @@ final class GestureEventTapEventMappingTests: XCTestCase { location: CGPoint(x: 12, y: 34) ) - let input = tap.inputEvent(for: .leftMouseDown, event: event) + let input = inputEvent(from: tap, for: .leftMouseDown, event: event) - XCTAssertEqual(input?.kind, .buttonDown(.primary)) + assertInputKind(input?.kind, .buttonDown(.primary)) XCTAssertEqual(input?.location, GesturePoint(x: 12, y: 34)) } - func testInputEventMapsPrimaryMouseDraggedAndUpWhenCaptured() throws { - let tap = GestureEventTap(capturedButtons: [.primary]) - - let dragged = tap.inputEvent( - for: .leftMouseDragged, - event: try mouseEvent(type: .leftMouseDragged, button: .left) - ) - let up = tap.inputEvent( - for: .leftMouseUp, - event: try mouseEvent(type: .leftMouseUp, button: .left) - ) - - XCTAssertEqual(dragged?.kind, .buttonDragged(.primary)) - XCTAssertEqual(up?.kind, .buttonUp(.primary)) - } - func testInputEventMapsMiddleMouseDownWhenCaptured() throws { let tap = GestureEventTap(capturedButtons: [.middle]) let event = try mouseEvent(type: .otherMouseDown, button: .center) - let input = tap.inputEvent(for: .otherMouseDown, event: event) - - XCTAssertEqual(input?.kind, .buttonDown(.middle)) - } - - func testInputEventMapsMiddleMouseDraggedAndUpWhenCaptured() throws { - let tap = GestureEventTap(capturedButtons: [.middle]) - - let dragged = tap.inputEvent( - for: .otherMouseDragged, - event: try mouseEvent(type: .otherMouseDragged, button: .center) - ) - let up = tap.inputEvent( - for: .otherMouseUp, - event: try mouseEvent(type: .otherMouseUp, button: .center) - ) + let input = inputEvent(from: tap, for: .otherMouseDown, event: event) - XCTAssertEqual(dragged?.kind, .buttonDragged(.middle)) - XCTAssertEqual(up?.kind, .buttonUp(.middle)) + assertInputKind(input?.kind, .buttonDown(.middle)) } func testInputEventMapsOtherMouseDownWhenCaptured() throws { - let additionalButton = try makeAdditionalButton() - let tap = GestureEventTap(capturedButtons: [additionalButton]) + let auxiliaryButton = try makeAuxiliaryButton() + let tap = GestureEventTap(capturedButtons: [auxiliaryButton]) let event = try mouseEvent(type: .otherMouseDown, button: makeExtraMouseButton()) - let input = tap.inputEvent(for: .otherMouseDown, event: event) + let input = inputEvent(from: tap, for: .otherMouseDown, event: event) - XCTAssertEqual(input?.kind, .buttonDown(additionalButton)) + assertInputKind(input?.kind, .buttonDown(auxiliaryButton)) } func testMappedInputEventUsesRecognizedOtherMouseButtonForSourceSignature() throws { @@ -125,28 +91,40 @@ final class GestureEventTapEventMappingTests: XCTestCase { let mappedInputEvent = tap.mappedInputEvent(for: .otherMouseDown, event: event) - XCTAssertEqual(mappedInputEvent?.input.kind, .buttonDown(.middle)) + assertInputKind(mappedInputEvent?.input.kind, .buttonDown(.middle)) XCTAssertEqual( mappedInputEvent?.sourceSignature, SyntheticEventSignature.replayed(type: .otherMouseDown, button: .middle) ) } - func testInputEventMapsOtherMouseDraggedAndUpWhenCaptured() throws { - let additionalButton = try makeAdditionalButton() - let tap = GestureEventTap(capturedButtons: [additionalButton]) + func testInputEventMapsConfiguredMoveCGEventsToButtonMovedInput() throws { + let auxiliaryButton = try makeAuxiliaryButton() - let dragged = tap.inputEvent( - for: .otherMouseDragged, - event: try mouseEvent(type: .otherMouseDragged, button: makeExtraMouseButton()) + try assertInputEventMapsMovedAndUp( + capturedButton: .secondary, + movedEventType: .rightMouseDragged, + upType: .rightMouseUp, + mouseButton: .right ) - let up = tap.inputEvent( - for: .otherMouseUp, - event: try mouseEvent(type: .otherMouseUp, button: makeExtraMouseButton()) + try assertInputEventMapsMovedAndUp( + capturedButton: .primary, + movedEventType: .leftMouseDragged, + upType: .leftMouseUp, + mouseButton: .left + ) + try assertInputEventMapsMovedAndUp( + capturedButton: .middle, + movedEventType: .otherMouseDragged, + upType: .otherMouseUp, + mouseButton: .center + ) + try assertInputEventMapsMovedAndUp( + capturedButton: auxiliaryButton, + movedEventType: .otherMouseDragged, + upType: .otherMouseUp, + mouseButton: makeExtraMouseButton() ) - - XCTAssertEqual(dragged?.kind, .buttonDragged(additionalButton)) - XCTAssertEqual(up?.kind, .buttonUp(additionalButton)) } func testInputEventMapsSupportedModifiers() throws { @@ -154,7 +132,7 @@ final class GestureEventTapEventMappingTests: XCTestCase { let event = try mouseEvent(type: .rightMouseDown, button: .right) event.flags = [.maskAlternate, .maskCommand, .maskControl, .maskShift] - let input = tap.inputEvent(for: .rightMouseDown, event: event) + let input = inputEvent(from: tap, for: .rightMouseDown, event: event) XCTAssertEqual(input?.modifiers, [.command, .option, .control, .shift]) } @@ -164,39 +142,11 @@ final class GestureEventTapEventMappingTests: XCTestCase { let event = try mouseEvent(type: .rightMouseDown, button: .right) event.flags = [.maskCommand, .maskSecondaryFn] - let input = tap.inputEvent(for: .rightMouseDown, event: event) + let input = inputEvent(from: tap, for: .rightMouseDown, event: event) XCTAssertEqual(input?.modifiers, [.command]) } - func testInputEventMapsSecondaryMouseDragged() throws { - let tap = GestureEventTap() - let event = try mouseEvent( - type: .rightMouseDragged, - button: .right, - location: CGPoint(x: 18, y: 21) - ) - - let input = tap.inputEvent(for: .rightMouseDragged, event: event) - - XCTAssertEqual(input?.kind, .buttonDragged(.secondary)) - XCTAssertEqual(input?.location, GesturePoint(x: 18, y: 21)) - } - - func testInputEventMapsSecondaryMouseUp() throws { - let tap = GestureEventTap() - let event = try mouseEvent( - type: .rightMouseUp, - button: .right, - location: CGPoint(x: 30, y: 42) - ) - - let input = tap.inputEvent(for: .rightMouseUp, event: event) - - XCTAssertEqual(input?.kind, .buttonUp(.secondary)) - XCTAssertEqual(input?.location, GesturePoint(x: 30, y: 42)) - } - func testInputEventMapsEscapeKeyDownToCancel() throws { let tap = GestureEventTap() let event = try XCTUnwrap( @@ -204,17 +154,37 @@ final class GestureEventTapEventMappingTests: XCTestCase { ) event.flags = [.maskControl] - let input = tap.inputEvent(for: .keyDown, event: event) + let input = inputEvent(from: tap, for: .keyDown, event: event) - XCTAssertEqual(input?.kind, .cancel) + assertInputKind(input?.kind, .cancel) XCTAssertEqual(input?.modifiers, [.control]) } + func testTapDisabledEventsMapToCancelInput() throws { + let tap = GestureEventTap() + let event = try mouseEvent(type: .rightMouseDragged, button: .right) + event.flags = [.maskShift] + + let timeoutInput = tap.mappedTapDisabledInputEvent( + for: .tapDisabledByTimeout, + event: event + )?.input + let userInput = tap.mappedTapDisabledInputEvent( + for: .tapDisabledByUserInput, + event: event + )?.input + + assertInputKind(timeoutInput?.kind, .cancel) + assertInputKind(userInput?.kind, .cancel) + XCTAssertEqual(timeoutInput?.modifiers, [.shift]) + XCTAssertEqual(userInput?.modifiers, [.shift]) + } + func testInputEventIgnoresNonEscapeKeyDown() throws { let tap = GestureEventTap() let event = try XCTUnwrap(CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true)) - XCTAssertNil(tap.inputEvent(for: .keyDown, event: event)) + XCTAssertNil(inputEvent(from: tap, for: .keyDown, event: event)) } func testInputEventIgnoresEscapeKeyDownWhenCapturedButtonsAreEmpty() throws { @@ -223,7 +193,7 @@ final class GestureEventTapEventMappingTests: XCTestCase { CGEvent(keyboardEventSource: nil, virtualKey: escapeVirtualKeyCode, keyDown: true) ) - XCTAssertNil(tap.inputEvent(for: .keyDown, event: event)) + XCTAssertNil(inputEvent(from: tap, for: .keyDown, event: event)) } func testInputEventIgnoresUncapturedMouseEvent() throws { @@ -236,41 +206,77 @@ final class GestureEventTapEventMappingTests: XCTestCase { mouseButton: .left )) - XCTAssertNil(tap.inputEvent(for: .leftMouseDown, event: event)) + XCTAssertNil(inputEvent(from: tap, for: .leftMouseDown, event: event)) } func testInputEventIgnoresUncapturedOtherMouseButton() throws { let tap = GestureEventTap(capturedButtons: [.middle]) let event = try mouseEvent(type: .otherMouseDown, button: makeExtraMouseButton()) - XCTAssertNil(tap.inputEvent(for: .otherMouseDown, event: event)) + XCTAssertNil(inputEvent(from: tap, for: .otherMouseDown, event: event)) } func testInputEventIgnoresOtherMouseEventsForPrimaryOrSecondaryButtonNumbers() throws { let tap = GestureEventTap(capturedButtons: [.primary, .secondary, .middle]) XCTAssertNil( - tap.inputEvent( + inputEvent( + from: tap, for: .otherMouseDown, event: try mouseEvent(type: .otherMouseDown, button: .left)) ) XCTAssertNil( - tap.inputEvent( + inputEvent( + from: tap, for: .otherMouseDown, event: try mouseEvent(type: .otherMouseDown, button: .right)) ) } } -private let escapeVirtualKeyCode: CGKeyCode = 53 +private let escapeVirtualKeyCode: UInt16 = 53 private let defaultMouseLocation = CGPoint(x: 12, y: 34) +private func inputEvent( + from tap: GestureEventTap, + for type: CGEventType, + event: CGEvent +) -> GestureInputEvent? { + tap.mappedInputEvent(for: type, event: event)?.input +} + +@MainActor +private func assertInputEventMapsMovedAndUp( + capturedButton: PointerButton, + movedEventType: CGEventType, + upType: CGEventType, + mouseButton: CGMouseButton, + file: StaticString = #filePath, + line: UInt = #line +) throws { + let tap = GestureEventTap(capturedButtons: [capturedButton]) + + let moved = inputEvent( + from: tap, + for: movedEventType, + event: try mouseEvent(type: movedEventType, button: mouseButton) + ) + let up = inputEvent( + from: tap, + for: upType, + event: try mouseEvent(type: upType, button: mouseButton) + ) + + assertInputKind(moved?.kind, .buttonMoved(capturedButton), file: file, line: line) + assertInputKind(up?.kind, .buttonUp(capturedButton), file: file, line: line) +} + private func makeExtraMouseButton() throws -> CGMouseButton { try XCTUnwrap(CGMouseButton(rawValue: 4)) } -private func makeAdditionalButton() throws -> PointerButton { - try XCTUnwrap(PointerButton(additionalButtonNumber: 4)) +private func makeAuxiliaryButton() throws -> PointerButton { + try XCTUnwrap(PointerButton(auxiliaryButtonID: 4)) } private func mouseEvent( @@ -287,8 +293,40 @@ private func mouseEvent( )) } -private func eventTapMask(for types: CGEventType...) -> CGEventMask { +private func eventTapMask(for types: CGEventType...) -> UInt64 { types.reduce(0) { mask, type in mask | (1 << type.rawValue) } } + +private func assertInputKind( + _ kind: GestureInputEvent.Kind?, + _ expected: ExpectedInputKind, + file: StaticString = #filePath, + line: UInt = #line +) { + guard let kind else { + XCTFail("Expected input kind \(expected)", file: file, line: line) + return + } + + switch (kind, expected) { + case let (.buttonDown(button), .buttonDown(expectedButton)): + XCTAssertEqual(button, expectedButton, file: file, line: line) + case let (.buttonMoved(button), .buttonMoved(expectedButton)): + XCTAssertEqual(button, expectedButton, file: file, line: line) + case let (.buttonUp(button), .buttonUp(expectedButton)): + XCTAssertEqual(button, expectedButton, file: file, line: line) + case (.cancel, .cancel): + break + default: + XCTFail("Unexpected input kind \(kind)", file: file, line: line) + } +} + +private enum ExpectedInputKind { + case buttonDown(PointerButton) + case buttonMoved(PointerButton) + case buttonUp(PointerButton) + case cancel +} diff --git a/Tests/PointerGestureKitCoreGraphicsTests/EventTap/GestureEventTapReplayPointCoalescingTests.swift b/Tests/PointerGestureKitCoreGraphicsTests/EventTap/GestureEventTapReplayPointCoalescingTests.swift new file mode 100644 index 0000000..26dfc5e --- /dev/null +++ b/Tests/PointerGestureKitCoreGraphicsTests/EventTap/GestureEventTapReplayPointCoalescingTests.swift @@ -0,0 +1,48 @@ +import XCTest + +import PointerGestureKit +@testable import PointerGestureKitCoreGraphics + +final class GestureEventTapReplayPointCoalescingTests: XCTestCase { + func testCoalescedReplayDragPointsKeepShortDragsUnchanged() { + let points = makePoints(count: 4) + + XCTAssertEqual( + coalescedReplayDragPoints(points, maximumPointCount: 4), + points + ) + } + + func testCoalescedReplayDragPointsKeepMaximumSizedDragsUnchanged() { + let points = makePoints(count: 64) + + XCTAssertEqual( + coalescedReplayDragPoints(points, maximumPointCount: 64), + points + ) + } + + func testCoalescedReplayDragPointsRetainFirstAndLastWithinLimit() { + let points = makePoints(count: 20) + + let coalesced = coalescedReplayDragPoints(points, maximumPointCount: 6) + + XCTAssertEqual(coalesced.count, 6) + XCTAssertEqual(coalesced.first, points.first) + XCTAssertEqual(coalesced.last, points.last) + } + + func testCoalescedReplayDragPointsKeepsAtLeastEndpoints() { + let points = makePoints(count: 5) + + let coalesced = coalescedReplayDragPoints(points, maximumPointCount: 1) + + XCTAssertEqual(coalesced, [points[0], points[4]]) + } +} + +private func makePoints(count: Int) -> [GesturePoint] { + (0..( - makeMatcher: { _ in Self.makeGestureMatcher() }, - onReplayRequested: eventTap.replay, - onMatch: { _ in }, - areModifiersSatisfied: { modifiers, _ in - modifiers.contains(.command) - } + + eventTap.stop() + eventTap.stop() + } + + func testStartReturnsFalseWhenCapturedButtonsAreEmpty() { + let eventTap = GestureEventTap(capturedButtons: []) + + let didStart = eventTap.start { _ in .consume } + + XCTAssertFalse(didStart) + } + + func testTapDisabledRecoveryDispatchesCancelImmediatelyOnMainActor() { + let eventTap = GestureEventTap() + var handledKinds: [GestureInputEvent.Kind] = [] + _ = eventTap.start { input in + handledKinds.append(input.kind) + return .consume + } + + eventTap.recoverFromTapDisabled( + input: GestureInputEvent( + kind: .cancel, + location: .zero, + modifiers: [] + ), + sourceSignature: .tapDisabledTimeout ) - let recognizer = GestureRecognizer( - eventSource: eventTap, - configuration: configuration + XCTAssertEqual(handledKinds, [.cancel]) + } + + func testTapDisabledRecoveryClearsPendingSyntheticSuppressionBeforeDispatchingCancel() { + let eventTap = GestureEventTap() + var pendingCountsSeenByHandler: [Int] = [] + _ = eventTap.start { input in + XCTAssertEqual(input.kind, .cancel) + pendingCountsSeenByHandler.append(eventTap.pendingSyntheticEventSignatureCount()) + eventTap.suppressNextSyntheticEventSignatures([ + .replayed(type: .leftMouseUp, button: .primary) + ]) + return .consume + } + eventTap.suppressNextSyntheticEventSignatures([ + .replayed(type: .rightMouseUp, button: .secondary) + ]) + + eventTap.recoverFromTapDisabled( + input: GestureInputEvent( + kind: .cancel, + location: .zero, + modifiers: [] + ), + sourceSignature: .tapDisabledTimeout ) - XCTAssertEqual(recognizer.snapshot.status.lifecycle, .idle) + XCTAssertEqual(pendingCountsSeenByHandler, [0]) + XCTAssertEqual(eventTap.pendingSyntheticEventSignatureCount(), 1) + } + + func testReadmeStyleDefaultButtonControllerTypeChecks() { + let controller = ReadmeStyleGestureController(makeGestureMatcher: Self.makeGestureMatcher) + + controller.configureDefaultButtonRecognizer() + + XCTAssertEqual(controller.recognizer?.snapshot.status.lifecycle, .idle) } func testReadmeStylePrimaryButtonConfigurationTypeChecks() { let eventTap = GestureEventTap(capturedButtons: [.primary]) let configuration = GestureRecognizerConfiguration( makeMatcher: { _ in Self.makeGestureMatcher() }, - onReplayRequested: eventTap.replay, + onReplayRequested: { request in eventTap.replay(request) }, onMatch: { _ in }, recognitionButton: .primary ) @@ -48,7 +106,62 @@ final class GestureEventTapUsageTests: XCTestCase { } } +private extension GestureEventTap { + func pendingSyntheticEventSignatureCount( + file: StaticString = #filePath, + line: UInt = #line + ) -> Int { + syntheticEventSuppression.pendingSignatureCount(file: file, line: line) + } +} + +private extension SyntheticEventSuppression { + func pendingSignatureCount(file: StaticString = #filePath, line: UInt = #line) -> Int { + guard + let signatures = Mirror(reflecting: self).children.first(where: { + $0.label == "pendingSignatures" + })?.value as? [SyntheticEventSignature] + else { + XCTFail( + "Expected SyntheticEventSuppression to store pendingSignatures", + file: file, + line: line + ) + return -1 + } + return signatures.count + } +} + private enum AppCommand: Sendable { case showInspector case focusSearch } + +@MainActor +private final class ReadmeStyleGestureController { + var recognizer: GestureRecognizer? + + private let makeGestureMatcher: @MainActor () -> GesturePatternMatcher + + init(makeGestureMatcher: @escaping @MainActor () -> GesturePatternMatcher) { + self.makeGestureMatcher = makeGestureMatcher + } + + func configureDefaultButtonRecognizer() { + let eventTap = GestureEventTap() + let configuration = GestureRecognizerConfiguration( + makeMatcher: { [makeGestureMatcher] _ in makeGestureMatcher() }, + onReplayRequested: { request in eventTap.replay(request) }, + onMatch: { _ in }, + areModifiersSatisfied: { modifiers, _ in + modifiers.contains(.command) + } + ) + + recognizer = GestureRecognizer( + eventSource: eventTap, + configuration: configuration + ) + } +} diff --git a/Tests/PointerGestureKitCoreGraphicsTests/EventTap/SyntheticEventSuppressionTests.swift b/Tests/PointerGestureKitCoreGraphicsTests/EventTap/SyntheticEventSuppressionTests.swift index 77cf5ed..aa0a7ba 100644 --- a/Tests/PointerGestureKitCoreGraphicsTests/EventTap/SyntheticEventSuppressionTests.swift +++ b/Tests/PointerGestureKitCoreGraphicsTests/EventTap/SyntheticEventSuppressionTests.swift @@ -4,62 +4,68 @@ import XCTest @testable import PointerGestureKitCoreGraphics final class SyntheticEventSuppressionTests: XCTestCase { - func testConsumeRemovesMatchingPendingReplayedEventsInOrder() { - var suppression = SyntheticEventSuppression() - - suppression.suppress([replayedRightMouseDown, replayedRightMouseUp]) - - XCTAssertTrue(suppression.consume(replayedRightMouseDown)) - XCTAssertTrue(suppression.consume(replayedRightMouseUp)) - XCTAssertFalse(suppression.consume(replayedRightMouseUp)) - } - - func testSuppressAppendsPendingReplayedEvents() { + func testConsumesPendingReplayedEventsInOrder() { var suppression = SyntheticEventSuppression() suppression.suppress([replayedRightMouseDown]) suppression.suppress([replayedRightMouseUp]) - XCTAssertTrue(suppression.consume(replayedRightMouseDown)) - XCTAssertTrue(suppression.consume(replayedRightMouseUp)) + XCTAssertTrue(suppression.consumeThrough(replayedRightMouseDown)) + XCTAssertTrue(suppression.consumeThrough(replayedRightMouseUp)) + XCTAssertTrue(suppression.consumeThrough(replayedRightMouseUp)) + XCTAssertFalse(suppression.consumeThrough(observedRightMouseUp)) } - func testConsumeDropsMissedEarlierSyntheticEventsWhenLaterEventArrives() { + func testDropsMissedEarlierReplayedEventsWhenLaterEventArrives() { var suppression = SyntheticEventSuppression() suppression.suppress([replayedRightMouseDown, replayedRightMouseUp]) - XCTAssertTrue(suppression.consume(replayedRightMouseUp)) - XCTAssertFalse(suppression.consume(replayedRightMouseDown)) - XCTAssertFalse(suppression.consume(replayedRightMouseUp)) + XCTAssertTrue(suppression.consumeThrough(replayedRightMouseUp)) + XCTAssertTrue(suppression.consumeThrough(replayedRightMouseDown)) + XCTAssertTrue(suppression.consumeThrough(replayedRightMouseUp)) } - func testConsumeLeavesPendingEventsWhenEventIsNotPending() { + func testKeepsPendingEventsUntilResetOrMatchingReplayArrives() { var suppression = SyntheticEventSuppression() suppression.suppress([replayedRightMouseUp]) - XCTAssertFalse(suppression.consume(replayedLeftMouseUp)) - XCTAssertTrue(suppression.consume(replayedRightMouseUp)) - } + XCTAssertTrue(suppression.consumeThrough(replayedLeftMouseUp)) + XCTAssertFalse(suppression.consumeThrough(observedRightMouseUp)) + XCTAssertTrue(suppression.consumeThrough(replayedRightMouseUp)) - func testRemoveAllClearsPendingReplayedEvents() { - var suppression = SyntheticEventSuppression() - - suppression.suppress([replayedRightMouseDown, replayedRightMouseUp]) + suppression.suppress([replayedRightMouseUp]) suppression.removeAll() - XCTAssertFalse(suppression.consume(replayedRightMouseDown)) - XCTAssertFalse(suppression.consume(replayedRightMouseUp)) + XCTAssertTrue(suppression.consumeThrough(replayedRightMouseUp)) } - func testConsumeRequiresSyntheticReplayMarker() { - var suppression = SyntheticEventSuppression() + func testIgnoresReplayedEventsEvenWhenCapacityDropsPendingSignature() { + var suppression = SyntheticEventSuppression(maximumPendingSignatureCount: 3) + let signatures = [ + SyntheticEventSignature.replayed(type: .rightMouseDown, button: .secondary), + SyntheticEventSignature.replayed(type: .rightMouseDragged, button: .secondary), + SyntheticEventSignature.replayed(type: .rightMouseUp, button: .secondary), + SyntheticEventSignature.replayed(type: .leftMouseDown, button: .primary), + SyntheticEventSignature.replayed(type: .leftMouseUp, button: .primary), + ] + + suppression.suppress(signatures) + + XCTAssertTrue(suppression.consumeThrough(signatures[0])) + XCTAssertTrue(suppression.consumeThrough(signatures[1])) + XCTAssertTrue(suppression.consumeThrough(signatures[2])) + XCTAssertTrue(suppression.consumeThrough(signatures[3])) + XCTAssertTrue(suppression.consumeThrough(signatures[4])) + } - suppression.suppress([replayedRightMouseUp]) + func testIgnoresUnexpectedReplayedEventsWithoutPendingSignature() { + var suppression = SyntheticEventSuppression() - XCTAssertFalse(suppression.consume(observedRightMouseUp)) - XCTAssertTrue(suppression.consume(replayedRightMouseUp)) + XCTAssertTrue(suppression.consumeThrough(replayedRightMouseDown)) + XCTAssertTrue(suppression.consumeThrough(replayedRightMouseUp)) + XCTAssertFalse(suppression.consumeThrough(observedRightMouseUp)) } } diff --git a/Tests/PointerGestureKitCoreGraphicsTests/PointerGestureKitCoreGraphicsPublicAPITests.swift b/Tests/PointerGestureKitCoreGraphicsTests/PointerGestureKitCoreGraphicsPublicAPITests.swift new file mode 100644 index 0000000..cbd8a35 --- /dev/null +++ b/Tests/PointerGestureKitCoreGraphicsTests/PointerGestureKitCoreGraphicsPublicAPITests.swift @@ -0,0 +1,35 @@ +import CoreGraphics +import PointerGestureKit +import PointerGestureKitCoreGraphics +import XCTest + +@MainActor +final class PointerGestureKitCoreGraphicsPublicAPITests: XCTestCase { + func testCoreGraphicsAdapterAPIsAreUsableFromPublicImport() { + let point = GesturePoint(CGPoint(x: 10, y: 20)) + let eventTap = GestureEventTap(capturedButtons: [.primary]) + let eventSource: any GestureEventSource = eventTap + let replay: (GestureReplayRequest) -> Void = eventTap.replay + + XCTAssertEqual(point, GesturePoint(x: 10, y: 20)) + XCTAssertEqual(point.cgPoint, CGPoint(x: 10, y: 20)) + XCTAssertFalse(GesturePoint(CGPoint(x: CGFloat.nan, y: 20)).isFinite) + XCTAssertTrue(eventSource === eventTap) + XCTAssertFalse(GestureEventTap(capturedButtons: []).start { _ in .consume }) + eventTap.stop() + _ = replay + } + + func testDocumentationExamplesAreUsableWithoutTestableImport() { + let gesturePoint = GesturePoint(CGPoint(x: 10, y: 20)) + let cgPoint = gesturePoint.cgPoint + let eventTap = GestureEventTap(capturedButtons: [.secondary]) + let replay: (GestureReplayRequest) -> Void = eventTap.replay + + replay(.click(button: .secondary, at: gesturePoint)) + + XCTAssertEqual(cgPoint, CGPoint(x: 10, y: 20)) + XCTAssertNotNil(eventTap) + } + +} diff --git a/Tests/PointerGestureKitTests/Events/GestureEventDispositionTests.swift b/Tests/PointerGestureKitTests/Events/GestureEventDispositionTests.swift index b77a0f3..579a393 100644 --- a/Tests/PointerGestureKitTests/Events/GestureEventDispositionTests.swift +++ b/Tests/PointerGestureKitTests/Events/GestureEventDispositionTests.swift @@ -3,15 +3,8 @@ import XCTest final class GestureEventDispositionTests: XCTestCase { func testCasesDefineOriginalEventHandling() { - XCTAssertTrue(originalEventIsSuppressed(by: .consume)) - XCTAssertFalse(originalEventIsSuppressed(by: .passThrough)) - } - - func testHashableContractSupportsCollections() { - XCTAssertEqual( - Set([.consume, .consume, .passThrough]), - [.consume, .passThrough] - ) + XCTAssertTrue(GestureEventDisposition.consume.consumesOriginalEvent) + XCTAssertFalse(GestureEventDisposition.passThrough.consumesOriginalEvent) } func testSendableContractAcceptsDispositionValues() { @@ -19,19 +12,7 @@ final class GestureEventDispositionTests: XCTestCase { assertSendable(GestureEventDisposition.passThrough) } - func testDoesNotExposeSerializationOrEnumerationContracts() { - XCTAssertFalse(GestureEventDisposition.self is any Codable.Type) - XCTAssertFalse(GestureEventDisposition.self is any RawRepresentable.Type) - XCTAssertFalse(GestureEventDisposition.self is any CaseIterable.Type) - XCTAssertFalse(GestureEventDisposition.self is any Error.Type) - } - - private func originalEventIsSuppressed(by disposition: GestureEventDisposition) -> Bool { - switch disposition { - case .consume: - true - case .passThrough: - false - } + func testEquatableContractDistinguishesActions() { + XCTAssertNotEqual(GestureEventDisposition.consume, .passThrough) } } diff --git a/Tests/PointerGestureKitTests/Events/GestureInputEventTests.swift b/Tests/PointerGestureKitTests/Events/GestureInputEventTests.swift index 1b8b11b..b2997e6 100644 --- a/Tests/PointerGestureKitTests/Events/GestureInputEventTests.swift +++ b/Tests/PointerGestureKitTests/Events/GestureInputEventTests.swift @@ -4,12 +4,12 @@ import XCTest final class GestureInputEventTests: XCTestCase { func testInitializerStoresKindLocationAndModifiers() { let event = GestureInputEvent( - kind: .buttonDragged(.secondary), + kind: .buttonMoved(.secondary), location: GesturePoint(x: 10, y: 20), modifiers: [.command, .shift] ) - XCTAssertEqual(event.kind, .buttonDragged(.secondary)) + assertKind(event.kind, .buttonMoved(.secondary)) XCTAssertEqual(event.location, GesturePoint(x: 10, y: 20)) XCTAssertEqual(event.modifiers, [.command, .shift]) } @@ -23,55 +23,6 @@ final class GestureInputEventTests: XCTestCase { XCTAssertEqual(event.modifiers, []) } - func testEqualityUsesKindLocationAndModifiers() { - let event = GestureInputEvent( - kind: .buttonDown(.secondary), - location: GesturePoint(x: 10, y: 20), - modifiers: [.command] - ) - - XCTAssertEqual( - event, - GestureInputEvent( - kind: .buttonDown(.secondary), - location: GesturePoint(x: 10, y: 20), - modifiers: [.command] - ) - ) - XCTAssertNotEqual( - event, - GestureInputEvent( - kind: .buttonUp(.secondary), - location: GesturePoint(x: 10, y: 20), - modifiers: [.command] - ) - ) - XCTAssertNotEqual( - event, - GestureInputEvent( - kind: .buttonDown(.primary), - location: GesturePoint(x: 10, y: 20), - modifiers: [.command] - ) - ) - XCTAssertNotEqual( - event, - GestureInputEvent( - kind: .buttonDown(.secondary), - location: GesturePoint(x: 10, y: 21), - modifiers: [.command] - ) - ) - XCTAssertNotEqual( - event, - GestureInputEvent( - kind: .buttonDown(.secondary), - location: GesturePoint(x: 10, y: 20), - modifiers: [.shift] - ) - ) - } - func testCancelEventKeepsSourceLocationAndModifiers() { let event = GestureInputEvent( kind: .cancel, @@ -79,42 +30,15 @@ final class GestureInputEventTests: XCTestCase { modifiers: [.option] ) - XCTAssertEqual(event.kind, .cancel) + assertKind(event.kind, .cancel) XCTAssertEqual(event.location, GesturePoint(x: 10, y: 20)) XCTAssertEqual(event.modifiers, [.option]) } - func testKindHashableContractSupportsCollections() { - XCTAssertEqual( - Set([ - .buttonDown(.secondary), - .buttonDragged(.secondary), - .buttonUp(.secondary), - .cancel, - .cancel, - ]), - [.buttonDown(.secondary), .buttonDragged(.secondary), .buttonUp(.secondary), .cancel] - ) - } - - func testHashableContractSupportsCollections() { - let event = GestureInputEvent( - kind: .buttonDown(.secondary), - location: GesturePoint(x: 10, y: 20), - modifiers: [.command] - ) - let same = GestureInputEvent( - kind: .buttonDown(.secondary), - location: GesturePoint(x: 10, y: 20), - modifiers: [.command] - ) - let different = GestureInputEvent( - kind: .buttonDown(.secondary), - location: GesturePoint(x: 10, y: 21), - modifiers: [.command] - ) - - XCTAssertEqual(Set([event, same, different]), [event, different]) + func testInputKindCasesExposeButtonPayloads() { + assertKind(.buttonDown(.primary), .buttonDown(.primary)) + assertKind(.buttonMoved(.secondary), .buttonMoved(.secondary)) + assertKind(.buttonUp(.middle), .buttonUp(.middle)) } func testSendableContractAcceptsEventAndKindValues() { @@ -125,18 +49,43 @@ final class GestureInputEventTests: XCTestCase { modifiers: [.command] ) ) - assertSendable(GestureInputEvent.Kind.buttonDragged(.secondary)) + assertSendable(GestureInputEvent.Kind.buttonMoved(.secondary)) + } + + func testEquatableContractComparesValues() { + let event = GestureInputEvent( + kind: .buttonMoved(.secondary), + location: GesturePoint(x: 10, y: 20), + modifiers: [.command] + ) + + XCTAssertEqual(event, event) } - func testDoesNotExposeSerializationOrEnumerationContracts() { - XCTAssertFalse(GestureInputEvent.self is any Codable.Type) - XCTAssertFalse(GestureInputEvent.self is any RawRepresentable.Type) - XCTAssertFalse(GestureInputEvent.self is any CaseIterable.Type) - XCTAssertFalse(GestureInputEvent.self is any Error.Type) + private func assertKind( + _ kind: GestureInputEvent.Kind, + _ expected: ExpectedKind, + file: StaticString = #filePath, + line: UInt = #line + ) { + switch (kind, expected) { + case let (.buttonDown(button), .buttonDown(expectedButton)): + XCTAssertEqual(button, expectedButton, file: file, line: line) + case let (.buttonMoved(button), .buttonMoved(expectedButton)): + XCTAssertEqual(button, expectedButton, file: file, line: line) + case let (.buttonUp(button), .buttonUp(expectedButton)): + XCTAssertEqual(button, expectedButton, file: file, line: line) + case (.cancel, .cancel): + break + default: + XCTFail("Unexpected input kind \(kind)", file: file, line: line) + } + } - XCTAssertFalse(GestureInputEvent.Kind.self is any Codable.Type) - XCTAssertFalse(GestureInputEvent.Kind.self is any RawRepresentable.Type) - XCTAssertFalse(GestureInputEvent.Kind.self is any CaseIterable.Type) - XCTAssertFalse(GestureInputEvent.Kind.self is any Error.Type) + private enum ExpectedKind { + case buttonDown(PointerButton) + case buttonMoved(PointerButton) + case buttonUp(PointerButton) + case cancel } } diff --git a/Tests/PointerGestureKitTests/Events/GestureModifierFlagsTests.swift b/Tests/PointerGestureKitTests/Events/GestureModifierFlagsTests.swift index d4537fe..93dfb51 100644 --- a/Tests/PointerGestureKitTests/Events/GestureModifierFlagsTests.swift +++ b/Tests/PointerGestureKitTests/Events/GestureModifierFlagsTests.swift @@ -9,33 +9,27 @@ final class GestureModifierFlagsTests: XCTestCase { XCTAssertTrue(flags.contains(.shift)) XCTAssertFalse(flags.contains(.option)) XCTAssertFalse(flags.contains(.control)) + XCTAssertEqual(Set([flags, flags, [.option]]), [flags, [.option]]) } - func testRawValuesUseStableKnownBitPositions() { + func testRawValuesUseKnownBitPositions() { XCTAssertEqual(GestureModifierFlags.command.rawValue, 1 << 0) XCTAssertEqual(GestureModifierFlags.option.rawValue, 1 << 1) XCTAssertEqual(GestureModifierFlags.control.rawValue, 1 << 2) XCTAssertEqual(GestureModifierFlags.shift.rawValue, 1 << 3) } - func testInitializerDiscardsUnknownRawBits() { + func testInitializerPreservesUnknownRawBits() { let flags = GestureModifierFlags(rawValue: (1 << 20) | GestureModifierFlags.command.rawValue) - XCTAssertEqual(flags, [.command]) + XCTAssertEqual(flags.rawValue, (1 << 20) | GestureModifierFlags.command.rawValue) + XCTAssertTrue(flags.contains(.command)) } - func testInitializerMasksNegativeRawValueToKnownBits() { + func testInitializerPreservesNegativeRawValue() { let flags = GestureModifierFlags(rawValue: -1) - XCTAssertEqual(flags, [.command, .option, .control, .shift]) - } - - func testHashableContractSupportsCollections() { - XCTAssertEqual( - Set([[.command], [.command], [.shift]]), - [ - [.command], [.shift], - ]) + XCTAssertEqual(flags.rawValue, -1) } func testSendableContractAcceptsModifierFlags() { @@ -43,9 +37,4 @@ final class GestureModifierFlagsTests: XCTestCase { assertSendable(GestureModifierFlags([.command, .shift])) } - func testDoesNotExposeSerializationOrEnumerationContracts() { - XCTAssertFalse(GestureModifierFlags.self is any Codable.Type) - XCTAssertFalse(GestureModifierFlags.self is any CaseIterable.Type) - XCTAssertFalse(GestureModifierFlags.self is any Error.Type) - } } diff --git a/Tests/PointerGestureKitTests/Events/GestureReplayRequestTests.swift b/Tests/PointerGestureKitTests/Events/GestureReplayRequestTests.swift index c2549a0..2b9f5de 100644 --- a/Tests/PointerGestureKitTests/Events/GestureReplayRequestTests.swift +++ b/Tests/PointerGestureKitTests/Events/GestureReplayRequestTests.swift @@ -2,48 +2,74 @@ import PointerGestureKit import XCTest final class GestureReplayRequestTests: XCTestCase { - func testCasesCarryReplayLocations() { - XCTAssertEqual( - GestureReplayRequest.click(button: .secondary, at: GesturePoint(x: 10, y: 20)).location, - GesturePoint(x: 10, y: 20) + func testReplayRequestCasesExposePayloads() { + assertReplayRequests( + [.click(button: .secondary, at: GesturePoint(x: 10, y: 20))], + [.click(button: .secondary, at: GesturePoint(x: 10, y: 20))] ) - XCTAssertEqual( - GestureReplayRequest.release(button: .secondary, at: GesturePoint(x: 30, y: 40)).location, - GesturePoint(x: 30, y: 40) + assertReplayRequests( + [ + .drag( + button: .primary, + points: [GesturePoint(x: 10, y: 20), GesturePoint(x: 20, y: 20)] + ) + ], + [ + .drag( + button: .primary, + points: [GesturePoint(x: 10, y: 20), GesturePoint(x: 20, y: 20)] + ) + ] ) - XCTAssertEqual( + assertReplayRequests( + [.release(button: .middle, at: GesturePoint(x: 30, y: 40))], + [.release(button: .middle, at: GesturePoint(x: 30, y: 40))] + ) + assertReplayRequests( + [ + .dragStart( + button: .secondary, + points: [GesturePoint(x: 10, y: 20), GesturePoint(x: 20, y: 20)] + ) + ], + [ + .dragStart( + button: .secondary, + points: [GesturePoint(x: 10, y: 20), GesturePoint(x: 20, y: 20)] + ) + ] + ) + } + + func testSendableContractAcceptsReplayRequests() { + assertSendable(GestureReplayRequest.click(button: .secondary, at: GesturePoint(x: 10, y: 20))) + assertSendable( GestureReplayRequest.drag( button: .secondary, - points: [GesturePoint(x: 10, y: 20), GesturePoint(x: 30, y: 40)] - ).location, - GesturePoint(x: 30, y: 40) - ) + points: [GesturePoint(x: 10, y: 20), GesturePoint(x: 20, y: 20)] + )) + assertSendable( + GestureReplayRequest.dragStart( + button: .secondary, + points: [GesturePoint(x: 10, y: 20), GesturePoint(x: 20, y: 20)] + )) + assertSendable( + GestureReplayRequest.release(button: .secondary, at: GesturePoint(x: 30, y: 40))) } - func testCasesCarryReplayButtons() { - XCTAssertEqual( - GestureReplayRequest.click(button: .primary, at: GesturePoint(x: 10, y: 20)).button, - .primary - ) - XCTAssertEqual( - GestureReplayRequest.drag(button: .middle, points: [GesturePoint(x: 10, y: 20)]).button, - .middle - ) - XCTAssertEqual( - GestureReplayRequest.release(button: .secondary, at: GesturePoint(x: 30, y: 40)).button, - .secondary + func testEquatableContractDistinguishesPayloads() { + let request = GestureReplayRequest.click(button: .secondary, at: .init(x: 10, y: 20)) + let same = GestureReplayRequest.click(button: .secondary, at: .init(x: 10, y: 20)) + let different = GestureReplayRequest.drag( + button: .secondary, + points: [GesturePoint(x: 10, y: 20), GesturePoint(x: 20, y: 20)] ) - } - func testEmptyDragRequestHasNoLocation() { - XCTAssertNil(GestureReplayRequest.drag(button: .secondary, points: []).location) + XCTAssertEqual(request, same) + XCTAssertNotEqual(request, different) } - func testEqualityUsesCaseAndLocation() { - XCTAssertEqual( - GestureReplayRequest.click(button: .secondary, at: .init(x: 10, y: 20)), - GestureReplayRequest.click(button: .secondary, at: .init(x: 10, y: 20)) - ) + func testEqualityUsesCaseButtonAndPayload() { XCTAssertNotEqual( GestureReplayRequest.click(button: .secondary, at: .init(x: 10, y: 20)), GestureReplayRequest.release(button: .secondary, at: .init(x: 10, y: 20)) @@ -61,39 +87,31 @@ final class GestureReplayRequestTests: XCTestCase { button: .secondary, points: [.init(x: 10, y: 20), .init(x: 20, y: 20)] ), - GestureReplayRequest.drag( + GestureReplayRequest.dragStart( button: .secondary, - points: [.init(x: 10, y: 20), .init(x: 21, y: 20)] + points: [.init(x: 10, y: 20), .init(x: 20, y: 20)] ) ) - } - - func testHashableContractSupportsCollections() { - let request = GestureReplayRequest.click(button: .secondary, at: .init(x: 10, y: 20)) - let same = GestureReplayRequest.click(button: .secondary, at: .init(x: 10, y: 20)) - let different = GestureReplayRequest.drag( - button: .secondary, - points: [.init(x: 10, y: 20), .init(x: 20, y: 20)] + XCTAssertNotEqual( + GestureReplayRequest.dragStart( + button: .secondary, + points: [.init(x: 10, y: 20), .init(x: 20, y: 20)] + ), + GestureReplayRequest.dragStart( + button: .secondary, + points: [.init(x: 10, y: 20), .init(x: 21, y: 20)] + ) ) - - XCTAssertEqual(Set([request, same, different]), [request, different]) - } - - func testSendableContractAcceptsReplayRequests() { - assertSendable(GestureReplayRequest.click(button: .secondary, at: GesturePoint(x: 10, y: 20))) - assertSendable( + XCTAssertNotEqual( GestureReplayRequest.drag( button: .secondary, - points: [GesturePoint(x: 10, y: 20), GesturePoint(x: 20, y: 20)] - )) - assertSendable( - GestureReplayRequest.release(button: .secondary, at: GesturePoint(x: 30, y: 40))) + points: [.init(x: 10, y: 20), .init(x: 20, y: 20)] + ), + GestureReplayRequest.drag( + button: .secondary, + points: [.init(x: 10, y: 20), .init(x: 21, y: 20)] + ) + ) } - func testDoesNotExposeSerializationOrEnumerationContracts() { - XCTAssertFalse(GestureReplayRequest.self is any Codable.Type) - XCTAssertFalse(GestureReplayRequest.self is any RawRepresentable.Type) - XCTAssertFalse(GestureReplayRequest.self is any CaseIterable.Type) - XCTAssertFalse(GestureReplayRequest.self is any Error.Type) - } } diff --git a/Tests/PointerGestureKitTests/Events/PointerButtonTests.swift b/Tests/PointerGestureKitTests/Events/PointerButtonTests.swift index 65a22ca..ec4c834 100644 --- a/Tests/PointerGestureKitTests/Events/PointerButtonTests.swift +++ b/Tests/PointerGestureKitTests/Events/PointerButtonTests.swift @@ -3,60 +3,49 @@ import XCTest final class PointerButtonTests: XCTestCase { func testCasesRepresentPlatformNeutralPointerButtons() throws { - let additionalButton = try makeAdditionalButton() + let auxiliaryButton = try makeAuxiliaryButton() - XCTAssertEqual(Set([.primary, .secondary, .middle, additionalButton]).count, 4) + XCTAssertEqual(Set([.primary, .secondary, .middle, auxiliaryButton]).count, 4) } - func testAdditionalButtonInitializerRejectsReservedButtonNumbers() { - XCTAssertNil(PointerButton(additionalButtonNumber: 0)) - XCTAssertNil(PointerButton(additionalButtonNumber: 1)) - XCTAssertNil(PointerButton(additionalButtonNumber: 2)) + func testAuxiliaryButtonInitializerRejectsReservedButtonIdentifiers() { + XCTAssertNil(PointerButton(auxiliaryButtonID: 0)) + XCTAssertNil(PointerButton(auxiliaryButtonID: 1)) + XCTAssertNil(PointerButton(auxiliaryButtonID: 2)) } - func testAdditionalButtonInitializerStoresAdditionalButtonNumber() { - XCTAssertEqual(PointerButton(additionalButtonNumber: 3)?.additionalButtonNumber, 3) - XCTAssertEqual(PointerButton(additionalButtonNumber: 4)?.additionalButtonNumber, 4) + func testAuxiliaryButtonInitializerStoresAuxiliaryButtonIdentifier() { + XCTAssertEqual(PointerButton(auxiliaryButtonID: 3)?.auxiliaryButtonID, 3) + XCTAssertEqual(PointerButton(auxiliaryButtonID: 4)?.auxiliaryButtonID, 4) XCTAssertEqual( - PointerButton(additionalButtonNumber: UInt32.max)?.additionalButtonNumber, + PointerButton(auxiliaryButtonID: UInt32.max)?.auxiliaryButtonID, UInt32.max ) - XCTAssertNil(PointerButton.primary.additionalButtonNumber) - XCTAssertNil(PointerButton.secondary.additionalButtonNumber) - XCTAssertNil(PointerButton.middle.additionalButtonNumber) + XCTAssertNil(PointerButton.primary.auxiliaryButtonID) + XCTAssertNil(PointerButton.secondary.auxiliaryButtonID) + XCTAssertNil(PointerButton.middle.auxiliaryButtonID) } - func testEqualityUsesAdditionalButtonNumber() throws { - let button = try XCTUnwrap(PointerButton(additionalButtonNumber: 4)) - let same = try XCTUnwrap(PointerButton(additionalButtonNumber: 4)) - let different = try XCTUnwrap(PointerButton(additionalButtonNumber: 5)) + func testEqualityUsesAuxiliaryButtonIdentifier() throws { + let button = try XCTUnwrap(PointerButton(auxiliaryButtonID: 4)) + let same = try XCTUnwrap(PointerButton(auxiliaryButtonID: 4)) + let different = try XCTUnwrap(PointerButton(auxiliaryButtonID: 5)) XCTAssertEqual(button, same) XCTAssertNotEqual(button, different) - } - - func testHashableContractSupportsCollections() throws { - let additionalButton = try makeAdditionalButton() - XCTAssertEqual( - Set([.primary, .primary, .secondary, additionalButton, additionalButton]), - [.primary, .secondary, additionalButton] + Set([button, same, different]), + [button, different] ) } func testSendableContractAcceptsPointerButtonValues() throws { assertSendable(PointerButton.primary) - assertSendable(try makeAdditionalButton()) + assertSendable(try makeAuxiliaryButton()) } - func testDoesNotExposeSerializationOrEnumerationContracts() { - XCTAssertFalse(PointerButton.self is any Codable.Type) - XCTAssertFalse(PointerButton.self is any RawRepresentable.Type) - XCTAssertFalse(PointerButton.self is any CaseIterable.Type) - XCTAssertFalse(PointerButton.self is any Error.Type) - } } -private func makeAdditionalButton() throws -> PointerButton { - try XCTUnwrap(PointerButton(additionalButtonNumber: 4)) +private func makeAuxiliaryButton() throws -> PointerButton { + try XCTUnwrap(PointerButton(auxiliaryButtonID: 4)) } diff --git a/Tests/PointerGestureKitTests/Geometry/GestureDirectionTests.swift b/Tests/PointerGestureKitTests/Geometry/GestureDirectionTests.swift index fe6bbbd..ecb94fd 100644 --- a/Tests/PointerGestureKitTests/Geometry/GestureDirectionTests.swift +++ b/Tests/PointerGestureKitTests/Geometry/GestureDirectionTests.swift @@ -2,10 +2,6 @@ import PointerGestureKit import XCTest final class GestureDirectionTests: XCTestCase { - func testAllCasesAreCardinalInDeclarationOrder() { - XCTAssertEqual(GestureDirection.allCases, [.up, .down, .left, .right]) - } - func testHashableContractSupportsCollections() { XCTAssertEqual( Set([.up, .up, .down, .left, .right]), @@ -16,10 +12,4 @@ final class GestureDirectionTests: XCTestCase { func testSendableContractAcceptsDirectionValues() { assertSendable(GestureDirection.up) } - - func testDoesNotExposeSerializationOrErrorContracts() { - XCTAssertFalse(GestureDirection.self is any Codable.Type) - XCTAssertFalse(GestureDirection.self is any RawRepresentable.Type) - XCTAssertFalse(GestureDirection.self is any Error.Type) - } } diff --git a/Tests/PointerGestureKitTests/Geometry/GestureMovementResolverTests.swift b/Tests/PointerGestureKitTests/Geometry/GestureMovementResolverTests.swift new file mode 100644 index 0000000..e63f073 --- /dev/null +++ b/Tests/PointerGestureKitTests/Geometry/GestureMovementResolverTests.swift @@ -0,0 +1,100 @@ +import XCTest + +@testable import PointerGestureKit + +final class GestureMovementResolverTests: XCTestCase { + func testMaximumAbsoluteAxisDeltaReturnsZeroWithoutMovement() { + XCTAssertEqual( + GestureMovementResolver.maximumAbsoluteAxisDelta( + from: GesturePoint(x: 10, y: 10), + to: GesturePoint(x: 10, y: 10) + ), + 0 + ) + } + + func testMaximumAbsoluteAxisDeltaReturnsLargerAbsoluteCoordinateDelta() { + XCTAssertEqual( + GestureMovementResolver.maximumAbsoluteAxisDelta( + from: GesturePoint(x: 10, y: 10), + to: GesturePoint(x: -5, y: 30) + ), + 20 + ) + XCTAssertEqual( + GestureMovementResolver.maximumAbsoluteAxisDelta( + from: GesturePoint(x: 10, y: 10), + to: GesturePoint(x: 35, y: 20) + ), + 25 + ) + } + + func testDominantAxisDirectionResolvesPureCardinalMovement() { + assertDominantAxisDirection(to: GesturePoint(x: 10, y: 0), is: .right) + assertDominantAxisDirection(to: GesturePoint(x: -10, y: 0), is: .left) + assertDominantAxisDirection(to: GesturePoint(x: 0, y: 10), is: .down) + assertDominantAxisDirection(to: GesturePoint(x: 0, y: -10), is: .up) + } + + func testDominantAxisDirectionResolvesOffAxisMovementByLargestAxis() { + assertDominantAxisDirection(to: GesturePoint(x: 10, y: 9), is: .right) + assertDominantAxisDirection(to: GesturePoint(x: -10, y: 9), is: .left) + assertDominantAxisDirection(to: GesturePoint(x: 10, y: 25), is: .down) + assertDominantAxisDirection(to: GesturePoint(x: -10, y: -25), is: .up) + } + + func testEqualAxisMovementHasNoDominantAxisDirection() { + assertNoDominantAxisDirection(to: .zero) + assertNoDominantAxisDirection(from: GesturePoint(x: 10, y: 10), to: GesturePoint(x: 10, y: 10)) + assertNoDominantAxisDirection(to: GesturePoint(x: 10, y: 10)) + assertNoDominantAxisDirection(to: GesturePoint(x: -10, y: 10)) + assertNoDominantAxisDirection(to: GesturePoint(x: 10, y: -10)) + assertNoDominantAxisDirection(to: GesturePoint(x: -10, y: -10)) + } + + func testDominantAxisDirectionUsesLargestAxisFromStartPoint() { + XCTAssertEqual( + GestureMovementResolver.dominantAxisDirection( + from: GesturePoint(x: 100, y: 100), + to: GesturePoint(x: 75, y: 110) + ), + .left + ) + XCTAssertEqual( + GestureMovementResolver.dominantAxisDirection( + from: GesturePoint(x: 100, y: 100), + to: GesturePoint(x: 110, y: 125) + ), + .down + ) + } + + private func assertDominantAxisDirection( + from start: GesturePoint = .zero, + to end: GesturePoint, + is expectedDirection: GestureDirection, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual( + GestureMovementResolver.dominantAxisDirection(from: start, to: end), + expectedDirection, + file: file, + line: line + ) + } + + private func assertNoDominantAxisDirection( + from start: GesturePoint = .zero, + to end: GesturePoint, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertNil( + GestureMovementResolver.dominantAxisDirection(from: start, to: end), + file: file, + line: line + ) + } +} diff --git a/Tests/PointerGestureKitTests/Geometry/GesturePointTests.swift b/Tests/PointerGestureKitTests/Geometry/GesturePointTests.swift index 2e0c9f0..be1130a 100644 --- a/Tests/PointerGestureKitTests/Geometry/GesturePointTests.swift +++ b/Tests/PointerGestureKitTests/Geometry/GesturePointTests.swift @@ -13,26 +13,14 @@ final class GesturePointTests: XCTestCase { XCTAssertEqual(GesturePoint.zero, GesturePoint(x: 0, y: 0)) } - func testInitializerReplacesNonFiniteCoordinatesWithZero() { - XCTAssertEqual(GesturePoint(x: .nan, y: .infinity), .zero) - XCTAssertEqual(GesturePoint(x: -.infinity, y: 12.5), GesturePoint(x: 0, y: 12.5)) - } - - func testHashableContractSupportsCollections() { - XCTAssertEqual( - Set([GesturePoint(x: 1, y: 2), GesturePoint(x: 1, y: 2), GesturePoint(x: 2, y: 1)]), - [GesturePoint(x: 1, y: 2), GesturePoint(x: 2, y: 1)] - ) + func testFiniteStateIsExplicit() { + XCTAssertTrue(GesturePoint(x: 1, y: 2).isFinite) + XCTAssertFalse(GesturePoint(x: .nan, y: 2).isFinite) + XCTAssertFalse(GesturePoint(x: 1, y: .infinity).isFinite) } func testSendableContractAcceptsPointValues() { assertSendable(GesturePoint(x: 1, y: 2)) } - func testDoesNotExposeSerializationOrEnumerationContracts() { - XCTAssertFalse(GesturePoint.self is any Codable.Type) - XCTAssertFalse(GesturePoint.self is any RawRepresentable.Type) - XCTAssertFalse(GesturePoint.self is any CaseIterable.Type) - XCTAssertFalse(GesturePoint.self is any Error.Type) - } } diff --git a/Tests/PointerGestureKitTests/Matching/GesturePatternCatalogTests.swift b/Tests/PointerGestureKitTests/Matching/GesturePatternCatalogTests.swift deleted file mode 100644 index 2cfc5ef..0000000 --- a/Tests/PointerGestureKitTests/Matching/GesturePatternCatalogTests.swift +++ /dev/null @@ -1,191 +0,0 @@ -import PointerGestureKit -import XCTest - -final class GesturePatternCatalogTests: XCTestCase { - func testInitializerPreservesHostDefinedPatternOrder() { - let patterns: [[GestureDirection]] = [ - [.down, .right], - [.left], - [], - [.up, .left], - ] - - let catalog = GesturePatternCatalog(patterns: patterns) - - XCTAssertEqual(catalog.patterns, patterns) - } - - func testEmptyCatalogIsValid() { - let catalog = GesturePatternCatalog(patterns: []) - - XCTAssertTrue(catalog.isValid) - XCTAssertTrue(catalog.validationIssues.isEmpty) - XCTAssertTrue(GesturePatternCatalog.validationIssues(for: []).isEmpty) - } - - func testUniqueNonEmptyPatternsAreValid() { - let catalog = GesturePatternCatalog( - patterns: [ - [.down, .right], - [.up, .left], - [.right], - ]) - - XCTAssertTrue(catalog.isValid) - XCTAssertTrue(catalog.validationIssues.isEmpty) - } - - func testValidationReportsEmptyPatterns() { - let issues = GesturePatternCatalog.validationIssues( - for: [ - [.down], - [], - [.up], - ]) - - XCTAssertEqual(issues, [.emptyPattern(index: 1)]) - } - - func testValidationReportsEveryEmptyPattern() { - let issues = GesturePatternCatalog.validationIssues( - for: [ - [], - [.right], - [], - ]) - - XCTAssertEqual( - issues, - [ - .emptyPattern(index: 0), - .emptyPattern(index: 2), - ]) - } - - func testValidationReportsDuplicatePatterns() { - let issues = GesturePatternCatalog.validationIssues( - for: [ - [.down, .right], - [.up], - [.down, .right], - ]) - - XCTAssertEqual( - issues, - [ - .duplicatePattern( - pattern: [.down, .right], - originalIndex: 0, - duplicateIndex: 2 - ) - ]) - } - - func testValidationReportsEachDuplicateAgainstFirstOccurrence() { - let issues = GesturePatternCatalog.validationIssues( - for: [ - [.left], - [.right], - [.left], - [.left], - ]) - - XCTAssertEqual( - issues, - [ - .duplicatePattern(pattern: [.left], originalIndex: 0, duplicateIndex: 2), - .duplicatePattern(pattern: [.left], originalIndex: 0, duplicateIndex: 3), - ]) - } - - func testValidationReportsMixedIssuesInPatternOrder() { - let catalog = GesturePatternCatalog( - patterns: [ - [.right], - [], - [.left], - [.right], - [], - ]) - - XCTAssertFalse(catalog.isValid) - XCTAssertEqual( - catalog.validationIssues, - [ - .emptyPattern(index: 1), - .duplicatePattern(pattern: [.right], originalIndex: 0, duplicateIndex: 3), - .emptyPattern(index: 4), - ]) - } - - func testShortAndLongPatternsWithSharedPrefixAreValidForExactMatching() { - let catalog = GesturePatternCatalog( - patterns: [ - [.up], - [.up, .right], - ]) - - XCTAssertTrue(catalog.validationIssues.isEmpty) - } - - func testIsValidReflectsEmptyPatterns() { - let catalog = GesturePatternCatalog(patterns: [[]]) - - XCTAssertFalse(catalog.isValid) - } - - func testIsValidReflectsDuplicatePatterns() { - let catalog = GesturePatternCatalog(patterns: [[.down], [.down]]) - - XCTAssertFalse(catalog.isValid) - } - - func testValidationIssueHashableContractSupportsCollections() { - let issues: Set = [ - .emptyPattern(index: 0), - .emptyPattern(index: 0), - .duplicatePattern(pattern: [.right], originalIndex: 1, duplicateIndex: 2), - ] - - XCTAssertEqual(issues.count, 2) - } - - func testValidationIssueExposesInvalidAndRelatedPatternIndices() { - let empty = GesturePatternCatalog.ValidationIssue.emptyPattern(index: 1) - let duplicate = GesturePatternCatalog.ValidationIssue.duplicatePattern( - pattern: [.right], - originalIndex: 0, - duplicateIndex: 3 - ) - - XCTAssertEqual(empty.patternIndex, 1) - XCTAssertNil(empty.relatedPatternIndex) - XCTAssertEqual(duplicate.patternIndex, 3) - XCTAssertEqual(duplicate.relatedPatternIndex, 0) - } - - func testHashableContractSupportsCollections() { - let catalog = GesturePatternCatalog(patterns: [[.right]]) - let same = GesturePatternCatalog(patterns: [[.right]]) - let different = GesturePatternCatalog(patterns: [[.left]]) - - XCTAssertEqual(Set([catalog, same, different]), [catalog, different]) - } - - func testSendableContractAcceptsCatalogAndIssues() { - assertSendable(GesturePatternCatalog(patterns: [[.right]])) - assertSendable(GesturePatternCatalog.ValidationIssue.emptyPattern(index: 0)) - } - - func testDoesNotExposeSerializationOrEnumerationContracts() { - XCTAssertFalse(GesturePatternCatalog.self is any Codable.Type) - XCTAssertFalse(GesturePatternCatalog.self is any RawRepresentable.Type) - XCTAssertFalse(GesturePatternCatalog.self is any CaseIterable.Type) - XCTAssertFalse(GesturePatternCatalog.self is any Error.Type) - - XCTAssertFalse(GesturePatternCatalog.ValidationIssue.self is any Codable.Type) - XCTAssertFalse(GesturePatternCatalog.ValidationIssue.self is any RawRepresentable.Type) - XCTAssertFalse(GesturePatternCatalog.ValidationIssue.self is any CaseIterable.Type) - XCTAssertFalse(GesturePatternCatalog.ValidationIssue.self is any Error.Type) - } -} diff --git a/Tests/PointerGestureKitTests/Matching/GesturePatternMatcherTests.swift b/Tests/PointerGestureKitTests/Matching/GesturePatternMatcherTests.swift index bd0602e..a52aec8 100644 --- a/Tests/PointerGestureKitTests/Matching/GesturePatternMatcherTests.swift +++ b/Tests/PointerGestureKitTests/Matching/GesturePatternMatcherTests.swift @@ -2,59 +2,39 @@ import PointerGestureKit import XCTest final class GesturePatternMatcherTests: XCTestCase { - func testMatchReturnsRegisteredExactPatternValue() { + func testRegisterRejectsEmptyPattern() { var matcher = GesturePatternMatcher() - matcher.register(pattern: [.up, .right], match: "A") + let result = matcher.register(pattern: [], match: "A") - XCTAssertEqual(matcher.match(pattern: [.up, .right]), "A") + XCTAssertFalse(result) + XCTAssertNil(matcher.match(pattern: [])) } - func testRegisterOverwritesExistingMatch() { + func testMatchReturnsRegisteredExactPattern() { var matcher = GesturePatternMatcher() - XCTAssertTrue(matcher.register(pattern: [.left], match: "first")) - XCTAssertTrue(matcher.register(pattern: [.left], match: "second")) - - XCTAssertEqual(matcher.match(pattern: [.left]), "second") - } - - func testMatchSupportsNonEquatableSendableValues() { - var matcher = GesturePatternMatcher() - matcher.register(pattern: [.down], match: NonEquatableMatch(id: 1)) - - XCTAssertEqual(matcher.match(pattern: [.down])?.id, 1) - } + matcher.register(pattern: [.up, .right], match: "A") - func testEmptyPatternNeverMatches() { - let matcher = GesturePatternMatcher() - XCTAssertNil(matcher.match(pattern: [])) - XCTAssertNil(matcher.match(pattern: [.up])) + XCTAssertEqual(matcher.match(pattern: [.up, .right]), "A") } - func testRegisterRejectsEmptyPattern() { + func testMatchReturnsNilForEmptyIncompleteAndUnregisteredPatterns() { var matcher = GesturePatternMatcher() - let didRegister = matcher.register(pattern: [], match: "A") + matcher.register(pattern: [.up, .right], match: "A") - XCTAssertFalse(didRegister) XCTAssertNil(matcher.match(pattern: [])) XCTAssertNil(matcher.match(pattern: [.up])) - } - - func testMatchReturnsNilWhenPatternIsMissing() { - var matcher = GesturePatternMatcher() - matcher.register(pattern: [.up], match: "A") XCTAssertNil(matcher.match(pattern: [.right])) } - func testMatchReturnsRegisteredBranchValuesWithSharedPrefix() { + func testRegisterReplacesExistingExactPatternMatch() { var matcher = GesturePatternMatcher() - matcher.register(pattern: [.up, .right], match: "A") - matcher.register(pattern: [.up, .left], match: "B") + matcher.register(pattern: [.up], match: "A") + matcher.register(pattern: [.up], match: "B") - XCTAssertEqual(matcher.match(pattern: [.up, .right]), "A") - XCTAssertEqual(matcher.match(pattern: [.up, .left]), "B") + XCTAssertEqual(matcher.match(pattern: [.up]), "B") } - func testMatchReturnsShortAndLongRegisteredPatternsWithSharedPrefix() { + func testSharedPrefixPatternsMatchDistinctTerminalValues() { var matcher = GesturePatternMatcher() matcher.register(pattern: [.up], match: "short") matcher.register(pattern: [.up, .right], match: "long") @@ -63,20 +43,6 @@ final class GesturePatternMatcherTests: XCTestCase { XCTAssertEqual(matcher.match(pattern: [.up, .right]), "long") } - func testMatchReturnsNilForIncompletePrefix() { - var matcher = GesturePatternMatcher() - matcher.register(pattern: [.up, .right], match: "A") - - XCTAssertNil(matcher.match(pattern: [.up])) - } - - func testMatchReturnsNilWhenPatternContinuesPastRegisteredMatch() { - var matcher = GesturePatternMatcher() - matcher.register(pattern: [.up], match: "A") - - XCTAssertNil(matcher.match(pattern: [.up, .right])) - } - func testSendableContractAcceptsMatcherValues() { var matcher = GesturePatternMatcher() matcher.register(pattern: [.up], match: "A") @@ -84,16 +50,12 @@ final class GesturePatternMatcherTests: XCTestCase { assertSendable(matcher) } - func testDoesNotExposeSerializationOrEnumerationContracts() { - XCTAssertFalse(GesturePatternMatcher.self is any Codable.Type) - XCTAssertFalse(GesturePatternMatcher.self is any RawRepresentable.Type) - XCTAssertFalse(GesturePatternMatcher.self is any CaseIterable.Type) - XCTAssertFalse(GesturePatternMatcher.self is any Error.Type) - } + func testRegisterAcceptsNonEquatableSendableMatchValues() { + var matcher = GesturePatternMatcher() + + let result = matcher.register(pattern: [.down], match: NonEquatableMatch(id: 1)) - func testDoesNotExposeEqualityOrHashingAsPublicContract() { - XCTAssertFalse(GesturePatternMatcher.self is any Equatable.Type) - XCTAssertFalse(GesturePatternMatcher.self is any Hashable.Type) + XCTAssertTrue(result) } private struct NonEquatableMatch: Sendable { diff --git a/Tests/PointerGestureKitTests/Observation/GestureObservationTokenTests.swift b/Tests/PointerGestureKitTests/Observation/GestureObservationTokenTests.swift new file mode 100644 index 0000000..ca45fc1 --- /dev/null +++ b/Tests/PointerGestureKitTests/Observation/GestureObservationTokenTests.swift @@ -0,0 +1,38 @@ +import XCTest + +@testable import PointerGestureKit + +@MainActor +final class GestureObservationTokenTests: XCTestCase { + func testCancelRunsHandlerSynchronouslyOnce() { + var cancelCount = 0 + let token = GestureObservationToken(onCancel: { + cancelCount += 1 + }) + + token.cancel() + token.cancel() + + XCTAssertEqual(cancelCount, 1) + } + + func testDeinitCancelsActiveTokenOnce() async { + var cancelCount = 0 + var token: GestureObservationToken? = GestureObservationToken(onCancel: { + cancelCount += 1 + }) + + XCTAssertNotNil(token) + token = nil + + await XCTAssertEventually { cancelCount == 1 } + XCTAssertEqual(cancelCount, 1) + } + + func testSendableContractAcceptsToken() { + let token = GestureObservationToken(onCancel: {}) + + assertSendable(token) + } + +} diff --git a/Tests/PointerGestureKitTests/Observation/GestureRecognizerObservationFilteringTests.swift b/Tests/PointerGestureKitTests/Observation/GestureRecognizerObservationFilteringTests.swift new file mode 100644 index 0000000..19cf10d --- /dev/null +++ b/Tests/PointerGestureKitTests/Observation/GestureRecognizerObservationFilteringTests.swift @@ -0,0 +1,749 @@ +import XCTest + +import PointerGestureKit + +@MainActor +final class GestureRecognizerObservationFilteringTests: XCTestCase { + func testObserveStatusSkipsTraceOnlyUpdates() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + recognizer.isRecordingModeEnabled = true + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 0, y: 0))) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + + var statuses: [GestureRecognizerState.Status] = [] + let token = recognizer.observeStatus { status in + statuses.append(status) + } + XCTAssertEqual(statuses.count, 1) + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 80)) + ) + XCTAssertEqual(recognizer.snapshot.trace.directions.last, .down) + XCTAssertEqual( + recognizer.snapshot.trace.directionEndpoints, + [.init(x: 0, y: 0), .init(x: 40, y: 0), .init(x: 40, y: 80)] + ) + + await XCTAssertNotEventually(timeout: 0.05) { statuses.count > 1 } + XCTAssertEqual(statuses.count, 1) + + _ = token + } + + func testObserveStatusEmitsRecordingModeChanges() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + var statuses: [GestureRecognizerState.Status] = [] + let token = recognizer.observeStatus { status in + statuses.append(status) + } + XCTAssertEqual(statuses.map(\.isRecordingModeEnabled), [false]) + + recognizer.isRecordingModeEnabled = true + await XCTAssertEventually(timeout: 0.2) { statuses.count >= 2 } + + XCTAssertEqual(statuses.map(\.isRecordingModeEnabled), [false, true]) + + _ = token + } + + func testObserveStatusEmitsFailureChanges() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + areModifiersSatisfied: { _, _ in false }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + var statuses: [GestureRecognizerState.Status] = [] + let token = recognizer.observeStatus { status in + statuses.append(status) + } + XCTAssertEqual(statuses.map(\.lastFailure), [nil]) + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 0, y: 0)) + ) + + await XCTAssertEventually(timeout: 0.2) { + statuses.contains { $0.lastFailure == .modifiersNotSatisfied } + } + XCTAssertEqual(statuses.map(\.lastFailure), [nil, .modifiersNotSatisfied]) + + _ = token + } + + func testObserveTraceSkipsStatusOnlyUpdates() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + XCTAssertEqual(traces.count, 1) + + recognizer.start() + XCTAssertEqual(recognizer.snapshot.status.lifecycle, .ready) + + await XCTAssertNotEventually(timeout: 0.05) { traces.count > 1 } + XCTAssertEqual(traces.count, 1) + + _ = token + } + + func testObserveTraceEmitsTraceChanges() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + XCTAssertEqual(traces.map(\.directions), [[]]) + + recognizer.isRecordingModeEnabled = true + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 0, y: 0))) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + + await XCTAssertEventually(timeout: 0.2) { traces.contains { $0.directions == [.right] } } + + XCTAssertEqual(traces.map(\.directions), [[], [.right]]) + XCTAssertEqual(traces.last?.directionEndpoints, [.init(x: 0, y: 0), .init(x: 40, y: 0)]) + + _ = token + } + + func testObserveTraceEmitsSameDirectionEndpointUpdatesImmediately() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + + recognizer.isRecordingModeEnabled = true + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero)) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 10, y: 0)) + ) + + await XCTAssertEventually(timeout: 0.2) { + traces.last?.directionEndpoints == [.zero, .init(x: 10, y: 0)] + } + let traceCountAfterFirstDirection = traces.count + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 20, y: 0)) + ) + + await XCTAssertEventually(timeout: 0.2) { + traces.count > traceCountAfterFirstDirection + && traces.last?.directionEndpoints == [.zero, .init(x: 20, y: 0)] + && traces.last?.tailPoint == .init(x: 20, y: 0) + } + XCTAssertEqual(traces.last?.rawPoints, [.zero, .init(x: 10, y: 0)]) + + _ = token + } + + func testObserveTracePublishesTailPointWhenVisibleTurnHasNotReachedDirectionThreshold() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 25 + ) + ) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + + recognizer.isRecordingModeEnabled = true + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero)) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + + await XCTAssertEventually(timeout: 0.2) { + traces.last?.directionEndpoints == [.zero, .init(x: 40, y: 0)] + } + let traceCountAfterFirstDirection = traces.count + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 45, y: 10)) + ) + + await XCTAssertEventually(timeout: 0.2) { + traces.count > traceCountAfterFirstDirection + && traces.last?.rawPoints == [.zero, .init(x: 40, y: 0)] + && traces.last?.directionEndpoints == [.zero, .init(x: 40, y: 0)] + && traces.last?.tailPoint == .init(x: 45, y: 10) + } + + _ = token + } + + func testObserveTraceEmitsVisibleTraceDuringRapidGestureCompletion() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + + recognizer.isRecordingModeEnabled = true + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + + XCTAssertTrue( + traces.contains { $0.isVisible && $0.directions == [.right] }, + "Recording overlays should not wait for the next main-actor turn before drawing fast input." + ) + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 40, y: 0)) + ) + + XCTAssertGreaterThanOrEqual(traces.count, 3) + XCTAssertEqual(traces.last?.isVisible, false) + + XCTAssertTrue( + traces.contains { !$0.isVisible && $0.directions.isEmpty }, + "Trace observation should still return to hidden after the rapid gesture completes." + ) + + _ = token + } + + func testRegisteredPrefixTraceVisibilityIsDeliveredWithoutMainActorYield() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right, .down], match: UUID()) + return matcher + }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + + XCTAssertTrue( + traces.last?.isVisible == true && traces.last?.directions == [.right], + "Registered prefixes should be available to the overlay before another main-actor turn." + ) + + let traceCountBeforeFinish = traces.count + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 40, y: 0)) + ) + + XCTAssertTrue( + traces.count > traceCountBeforeFinish && traces.last?.isVisible == false + ) + + _ = token + } + + func testExactMatchTraceVisibilityIsDeliveredWithoutMainActorYield() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: UUID()) + return matcher + }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + + XCTAssertTrue( + traces.last?.isVisible == true && traces.last?.directions == [.right], + "Exact matches should be available to the overlay before another main-actor turn." + ) + + _ = token + } + + func testObserveTracePublishesVisibleTailUpdatesWithoutWaitingForMainActorYield() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + + recognizer.isRecordingModeEnabled = true + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 10, y: 0)) + ) + + await XCTAssertEventually(timeout: 0.2) { + traces.last?.isVisible == true && traces.last?.tailPoint == .init(x: 10, y: 0) + } + let visibleTraceCountBeforeTailUpdates = traces.filter(\.isVisible).count + + for x in [20.0, 30.0, 40.0] { + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: x, y: 0)) + ) + } + + let visibleTraces = traces.filter(\.isVisible) + XCTAssertEqual( + Array(visibleTraces.dropFirst(visibleTraceCountBeforeTailUpdates).map(\.tailPoint)), + [ + .init(x: 20, y: 0), + .init(x: 30, y: 0), + .init(x: 40, y: 0), + ], + "Visible trace updates should keep pace with pointer movement before overlay rendering." + ) + XCTAssertEqual( + Array(visibleTraces.dropFirst(visibleTraceCountBeforeTailUpdates).map(\.directions)), + [[.right], [.right], [.right]] + ) + + _ = token + } + + func testCancelingTraceObservationDuringLowLatencyNotificationDoesNotInterruptDelivery() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + var firstToken: GestureObservationToken? + var firstTraces: [GestureRecognizerState.Trace] = [] + var secondTraces: [GestureRecognizerState.Trace] = [] + + firstToken = recognizer.observeTrace { trace in + firstTraces.append(trace) + if trace.isVisible { + firstToken?.cancel() + } + } + let secondToken = recognizer.observeTrace { trace in + secondTraces.append(trace) + } + + recognizer.isRecordingModeEnabled = true + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 10, y: 0)) + ) + + XCTAssertTrue(firstTraces.contains { $0.isVisible && $0.directions == [.right] }) + XCTAssertTrue(secondTraces.contains { $0.isVisible && $0.directions == [.right] }) + + let firstTraceCountAfterCancel = firstTraces.count + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 20, y: 0)) + ) + + await XCTAssertEventually(timeout: 0.2) { + secondTraces.last?.tailPoint == .init(x: 20, y: 0) + } + await XCTAssertNotEventually(timeout: 0.05) { + firstTraces.count > firstTraceCountAfterCancel + } + + XCTAssertEqual(firstTraces.count, firstTraceCountAfterCancel) + + _ = secondToken + } + + func testCancelingAnotherTraceObservationDuringLowLatencyNotificationDoesNotInterruptDelivery() + async + { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + var secondToken: GestureObservationToken? + var firstTraces: [GestureRecognizerState.Trace] = [] + var secondTraces: [GestureRecognizerState.Trace] = [] + + let firstToken = recognizer.observeTrace { trace in + firstTraces.append(trace) + if trace.isVisible { + secondToken?.cancel() + } + } + secondToken = recognizer.observeTrace { trace in + secondTraces.append(trace) + } + + recognizer.isRecordingModeEnabled = true + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 10, y: 0)) + ) + + XCTAssertTrue(firstTraces.contains { $0.isVisible && $0.directions == [.right] }) + XCTAssertTrue(secondTraces.contains { $0.isVisible && $0.directions == [.right] }) + + let secondTraceCountAfterCancel = secondTraces.count + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 20, y: 0)) + ) + + await XCTAssertEventually(timeout: 0.2) { + firstTraces.last?.tailPoint == .init(x: 20, y: 0) + } + await XCTAssertNotEventually(timeout: 0.05) { + secondTraces.count > secondTraceCountAfterCancel + } + + XCTAssertEqual(secondTraces.count, secondTraceCountAfterCancel) + + _ = firstToken + _ = secondToken + } + + func testObserveTraceHidesImmediatelyAfterRegisteredPrefixMismatch() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right, .down], match: UUID()) + return matcher + }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + XCTAssertTrue(traces.last?.isVisible == true && traces.last?.directions == [.right]) + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: -40)) + ) + + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + + XCTAssertEqual(traces.map(\.isVisible), [false, true, false]) + + _ = token + } + + func testObserveTraceShowsRegisteredPrefixBeforeExactMatch() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right, .down], match: UUID()) + return matcher + }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + + XCTAssertEqual(recognizer.snapshot.trace.directions, [.right]) + XCTAssertTrue(recognizer.snapshot.trace.isVisible) + await XCTAssertEventually(timeout: 0.2) { + traces.contains { $0.isVisible && $0.directions == [.right] } + } + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 40)) + ) + + await XCTAssertEventually(timeout: 0.2) { + traces.contains { $0.isVisible && $0.directions == [.right, .down] } + } + + _ = token + } + + func testObserveTraceReturnsToHiddenAfterVisibleExactMatchMismatch() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: UUID()) + return matcher + }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + + await XCTAssertEventually(timeout: 0.2) { + traces.contains { $0.isVisible && $0.directions == [.right] } + } + let traceCountAfterVisibleMatch = traces.count + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 40)) + ) + + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + XCTAssertTrue(recognizer.snapshot.trace.directions.isEmpty) + + await XCTAssertEventually(timeout: 0.2) { + traces.count > traceCountAfterVisibleMatch + && traces.last?.isVisible == false + && traces.last?.directions.isEmpty == true + } + + _ = token + } + + func testObserveTraceKeepsVisibleWhenMatchContinuesIntoRegisteredPrefix() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: UUID()) + matcher.register(pattern: [.right, .down, .left], match: UUID()) + return matcher + }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + var traces: [GestureRecognizerState.Trace] = [] + let token = recognizer.observeTrace { trace in + traces.append(trace) + } + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + + await XCTAssertEventually(timeout: 0.2) { + traces.contains { $0.isVisible && $0.directions == [.right] } + } + let traceCountAfterVisibleMatch = traces.count + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 40)) + ) + + XCTAssertEqual(recognizer.snapshot.trace.directions, [.right, .down]) + XCTAssertTrue(recognizer.snapshot.trace.isVisible) + await XCTAssertEventually(timeout: 0.2) { + traces.count > traceCountAfterVisibleMatch + && traces.last?.isVisible == true + && traces.last?.directions == [.right, .down] + } + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 0, y: 40)) + ) + + await XCTAssertEventually(timeout: 0.2) { + traces.contains { $0.isVisible && $0.directions == [.right, .down, .left] } + } + + _ = token + } + + func testDeferredTraceVisibilityNotificationsDoNotReplayToNewObservers() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + var firstObserverTraces: [GestureRecognizerState.Trace] = [] + let firstToken = recognizer.observeTrace { trace in + firstObserverTraces.append(trace) + } + + recognizer.isRecordingModeEnabled = true + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 40, y: 0)) + ) + + var secondObserverTraces: [GestureRecognizerState.Trace] = [] + let secondToken = recognizer.observeTrace { trace in + secondObserverTraces.append(trace) + } + + await XCTAssertNotEventually(timeout: 0.05) { + secondObserverTraces.contains { $0.isVisible } + } + XCTAssertEqual(secondObserverTraces.map(\.isVisible), [false]) + + _ = firstObserverTraces + _ = firstToken + _ = secondToken + } + + private func makeRecognizer(eventSource: GestureEventSourceDouble) -> GestureRecognizer { + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + + return GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + } +} diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerObservationTests.swift b/Tests/PointerGestureKitTests/Observation/GestureRecognizerObservationTests.swift similarity index 72% rename from Tests/PointerGestureKitTests/Recognizer/GestureRecognizerObservationTests.swift rename to Tests/PointerGestureKitTests/Observation/GestureRecognizerObservationTests.swift index abc9cae..5bad985 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerObservationTests.swift +++ b/Tests/PointerGestureKitTests/Observation/GestureRecognizerObservationTests.swift @@ -4,34 +4,13 @@ import PointerGestureKit @MainActor final class GestureRecognizerObservationTests: XCTestCase { - func testObserveCoalescesStateChanges() async { - let eventSource = GestureEventSourceDouble(startResult: true) - let recognizer = makeRecognizer(eventSource: eventSource) - - var callCount = 0 - let token = recognizer.observe { _ in - callCount += 1 - } - - XCTAssertEqual(callCount, 1) // initial delivery - - recognizer.start() - await XCTAssertEventually(timeout: 0.2) { callCount >= 2 } - XCTAssertEqual(callCount, 2) - - recognizer.stop() - await XCTAssertEventually(timeout: 0.2) { callCount >= 3 } - XCTAssertEqual(callCount, 3) - - _ = token - } - - func testObserveReceivesStoppedSnapshot() async { + func testObserveEmitsInitialReadyAndStoppedSnapshots() async { let eventSource = GestureEventSourceDouble(startResult: true) let recognizer = makeRecognizer(eventSource: eventSource) var snapshots: [GestureRecognizerState] = [] let token = recognizer.observe { snapshots.append($0) } + XCTAssertEqual(snapshots.map(\.status.lifecycle), [.idle]) recognizer.start() await XCTAssertEventually(timeout: 0.2) { snapshots.contains { $0.status.lifecycle == .ready } } @@ -46,6 +25,7 @@ final class GestureRecognizerObservationTests: XCTestCase { XCTAssertNotEqual(snapshots.last?.status.lifecycle, .ready) XCTAssertEqual(snapshots.last?.status.isCapturingGesture, false) + XCTAssertEqual(snapshots.map(\.status.lifecycle), [.idle, .ready, .idle]) _ = token } @@ -97,25 +77,13 @@ final class GestureRecognizerObservationTests: XCTestCase { XCTAssertEqual(callCount, countAfterCancel) } - func testCancelObservationFromNonMainActorStopsNotifications() async { + func testObservationTokenIsSendable() { let eventSource = GestureEventSourceDouble(startResult: true) let recognizer = makeRecognizer(eventSource: eventSource) - var callCount = 0 - let token = recognizer.observe { _ in - callCount += 1 - } - XCTAssertEqual(callCount, 1) - - await Task.detached { - token.cancel() - }.value - await Task.yield() - - recognizer.start() - await XCTAssertNotEventually(timeout: 0.2) { callCount > 1 } + let token = recognizer.observe { _ in } - XCTAssertEqual(callCount, 1) + assertSendable(token) } func testCancelingObservationDuringNotificationDoesNotInterruptDelivery() async { @@ -155,6 +123,36 @@ final class GestureRecognizerObservationTests: XCTestCase { _ = secondToken } + func testCancelingAnotherObservationDuringNotificationDoesNotInterruptDelivery() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + var secondToken: GestureObservationToken? + var firstCallCount = 0 + var secondCallCount = 0 + + let firstToken = recognizer.observe { _ in + firstCallCount += 1 + if firstCallCount > 1 { + secondToken?.cancel() + } + } + secondToken = recognizer.observe { _ in + secondCallCount += 1 + } + + recognizer.start() + await XCTAssertEventually(timeout: 0.2) { + firstCallCount >= 2 && secondCallCount >= 2 + } + + XCTAssertEqual(firstCallCount, 2) + XCTAssertEqual(secondCallCount, 2) + + _ = firstToken + _ = secondToken + } + func testStateChangeDuringObservationSchedulesFollowUpNotification() async { let eventSource = GestureEventSourceDouble(startResult: true) let recognizer = makeRecognizer(eventSource: eventSource) @@ -178,6 +176,37 @@ final class GestureRecognizerObservationTests: XCTestCase { _ = token } + func testObserverAddedDuringPendingNotificationDoesNotReceiveDuplicateCurrentSnapshot() async { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = makeRecognizer(eventSource: eventSource) + + var firstObserverSnapshots: [GestureRecognizerState] = [] + let firstToken = recognizer.observe { state in + firstObserverSnapshots.append(state) + } + + recognizer.start() + + var secondObserverSnapshots: [GestureRecognizerState] = [] + let secondToken = recognizer.observe { state in + secondObserverSnapshots.append(state) + } + + XCTAssertEqual(secondObserverSnapshots.map(\.status.lifecycle), [.ready]) + + await XCTAssertEventually(timeout: 0.2) { + firstObserverSnapshots.map(\.status.lifecycle) == [.idle, .ready] + } + await XCTAssertNotEventually(timeout: 0.05) { + secondObserverSnapshots.count > 1 + } + + XCTAssertEqual(secondObserverSnapshots.map(\.status.lifecycle), [.ready]) + + _ = firstToken + _ = secondToken + } + func testDroppingObservationTokenStopsNotifications() async { let eventSource = GestureEventSourceDouble(startResult: true) let recognizer = makeRecognizer(eventSource: eventSource) @@ -190,6 +219,7 @@ final class GestureRecognizerObservationTests: XCTestCase { XCTAssertEqual(callCount, 1) token = nil + await Task.yield() recognizer.start() await XCTAssertNotEventually(timeout: 0.2) { callCount > 1 } diff --git a/Tests/PointerGestureKitTests/PointerGestureKitPublicAPITests.swift b/Tests/PointerGestureKitTests/PointerGestureKitPublicAPITests.swift new file mode 100644 index 0000000..0d5e6ec --- /dev/null +++ b/Tests/PointerGestureKitTests/PointerGestureKitPublicAPITests.swift @@ -0,0 +1,267 @@ +import XCTest + +import PointerGestureKit + +@MainActor +final class PointerGestureKitPublicAPITests: XCTestCase { + func testCoreRecognizerAndMatchingAPIsAreUsableFromPublicImport() throws { + var matcher = GesturePatternMatcher() + var emptyMatcher: GesturePatternMatcher = .init() + let registerResult = matcher.register(pattern: [.down, .right], match: .showInspector) + let emptyRegisterResult = emptyMatcher.register(pattern: [], match: .showInspector) + + let context = try XCTUnwrap(GestureRecognitionContext(identifier: " editor ")) + let tuning = try GestureRecognizerTuning.validated( + minimumGestureStartAxisDistance: 6, + minimumDirectionChangeAxisDistance: 4, + maximumGestureSessionDuration: 1, + maximumRawPointCount: 32, + eventSourceStartRetryDelays: [] + ) + let configuration = GestureRecognizerConfiguration.init( + makeMatcher: { receivedContext in + XCTAssertEqual(receivedContext, context) + return matcher + }, + onReplayRequested: { _ in }, + onMatch: { _ in }, + recognitionButton: .secondary, + recognitionContext: { _ in context }, + isRecognitionEnabled: { $0 == context }, + passesThroughEmptyMatcher: true, + areModifiersSatisfied: { modifiers, _ in modifiers.contains(.command) }, + tuning: tuning + ) + let recognizer = GestureRecognizer( + eventSource: PublicGestureEventSource(), + configuration: configuration + ) + recognizer.isRecordingModeEnabled = true + let duplicateTuningUpdateResult: Bool = recognizer.updateTuning(tuning) + recognizer.retryStartNow() + recognizer.cancelActiveGesture() + + let state: GestureRecognizerState = recognizer.snapshot + let trace: GestureRecognizerState.Trace = state.trace + let lifecycle: GestureRecognizerState.Status.Lifecycle = .idle + let failures: [GestureRecognizerFailure] = [ + .eventSourceStartFailed, + .recognitionDisabled, + .modifiersNotSatisfied, + .gestureSessionExpired, + ] + let statusToken = recognizer.observeStatus { status in + XCTAssertEqual(status.lifecycle, .idle) + } + let traceToken = recognizer.observeTrace { trace in + XCTAssertFalse(trace.isVisible) + } + + XCTAssertEqual(state.status.lifecycle, .idle) + XCTAssertEqual(lifecycle, state.status.lifecycle) + XCTAssertNotEqual(state.status.lifecycle, .starting) + XCTAssertNotEqual(state.status.lifecycle, .ready) + XCTAssertNotEqual(state.status.lifecycle, .retrying) + XCTAssertNotEqual(state.status.lifecycle, .failed) + XCTAssertFalse(state.status.isCapturingGesture) + XCTAssertTrue(state.status.isRecordingModeEnabled) + XCTAssertEqual(state.status.lastRecordedDirections, []) + XCTAssertNil(state.status.lastFailure) + XCTAssertTrue(registerResult) + XCTAssertFalse(emptyRegisterResult) + XCTAssertFalse(duplicateTuningUpdateResult) + XCTAssertNil(emptyMatcher.match(pattern: [])) + XCTAssertFalse(trace.isVisible) + XCTAssertEqual(trace.rawPoints, []) + XCTAssertEqual(trace.directions, []) + XCTAssertEqual(trace.directionEndpoints, []) + XCTAssertNil(trace.tailPoint) + XCTAssertEqual(tuning.minimumGestureStartAxisDistance, 6) + XCTAssertEqual(tuning.minimumDirectionChangeAxisDistance, 4) + XCTAssertEqual(tuning.maximumGestureSessionDuration, 1) + XCTAssertEqual(tuning.maximumRawPointCount, 32) + XCTAssertEqual(tuning.eventSourceStartRetryDelays, []) + XCTAssertEqual(context.identifier, "editor") + XCTAssertTrue(failures.contains(.recognitionDisabled)) + statusToken.cancel() + traceToken.cancel() + } + + func testCoreValueAPIsAreUsableFromPublicImport() { + let point = GesturePoint(x: .infinity, y: 12) + let input = GestureInputEvent( + kind: .buttonDown(.secondary), + location: point, + modifiers: [.command, .shift] + ) + let inputKind: GestureInputEvent.Kind = .cancel + let direction: GestureDirection = .up + let modifiers: GestureModifierFlags = [.command, .option, .control, .shift] + let emptyModifiers = GestureModifierFlags(rawValue: 0) + let normalizedModifiers = GestureModifierFlags( + rawValue: GestureModifierFlags.command.rawValue | (1 << 20) + ) + + XCTAssertEqual(point.x, .infinity) + XCTAssertFalse(point.isFinite) + XCTAssertEqual(point.y, 12) + XCTAssertEqual(input.kind, .buttonDown(.secondary)) + XCTAssertEqual( + GestureInputEvent.Kind.buttonMoved(.secondary), + .buttonMoved(.secondary) + ) + XCTAssertEqual(GestureInputEvent.Kind.buttonUp(.secondary), .buttonUp(.secondary)) + XCTAssertEqual(inputKind, .cancel) + XCTAssertEqual(direction, .up) + XCTAssertTrue(input.modifiers.contains(.command)) + XCTAssertTrue(modifiers.contains(.option)) + XCTAssertTrue(modifiers.contains(.control)) + XCTAssertTrue(modifiers.contains(.shift)) + XCTAssertEqual(emptyModifiers.rawValue, 0) + XCTAssertEqual( + normalizedModifiers.rawValue, + GestureModifierFlags.command.rawValue | (1 << 20) + ) + XCTAssertEqual(PointerButton.primary.auxiliaryButtonID, nil) + XCTAssertEqual(PointerButton.secondary.auxiliaryButtonID, nil) + XCTAssertEqual(PointerButton.middle.auxiliaryButtonID, nil) + XCTAssertEqual(PointerButton(auxiliaryButtonID: 4)?.auxiliaryButtonID, 4) + XCTAssertEqual(GestureEventDisposition.consume.consumesOriginalEvent, true) + XCTAssertEqual(GestureEventDisposition.passThrough.consumesOriginalEvent, false) + XCTAssertEqual( + GestureReplayRequest.click(button: .secondary, at: .zero), + .click(button: .secondary, at: .zero) + ) + XCTAssertEqual( + GestureReplayRequest.drag(button: .secondary, points: [.zero, point]), + .drag(button: .secondary, points: [.zero, point]) + ) + XCTAssertEqual( + GestureReplayRequest.release(button: .secondary, at: point), + .release(button: .secondary, at: point) + ) + } + + func testObservationTokenAPIIsUsableFromPublicImport() { + let token: GestureObservationToken = PublicObservationSource().observe() + + token.cancel() + } + + func testRecognizerSnapshotValuesAreConstructibleFromPublicImport() { + let status = GestureRecognizerState.Status( + lifecycle: .ready, + isCapturingGesture: true, + isRecordingModeEnabled: true, + lastRecordedDirections: [.up, .right], + lastFailure: .modifiersNotSatisfied + ) + let trace = GestureRecognizerState.Trace( + isVisible: true, + rawPoints: [.zero, GesturePoint(x: 12, y: 24)], + directions: [.up], + directionEndpoints: [.zero, GesturePoint(x: 0, y: -20)], + tailPoint: GesturePoint(x: 12, y: 24) + ) + let partialTrace = GestureRecognizerState.Trace( + rawPoints: [.zero, GesturePoint(x: 12, y: 0), GesturePoint(x: 12, y: 24)], + directions: [.right, .down], + directionEndpoints: [.zero] + ) + let state = GestureRecognizerState(status: status, trace: trace) + + XCTAssertEqual(state.status.lifecycle, .ready) + XCTAssertTrue(state.status.isCapturingGesture) + XCTAssertTrue(state.status.isRecordingModeEnabled) + XCTAssertEqual(state.status.lastRecordedDirections, [.up, .right]) + XCTAssertEqual(state.status.lastFailure, .modifiersNotSatisfied) + XCTAssertTrue(state.trace.isVisible) + XCTAssertEqual(state.trace.rawPoints, [.zero, GesturePoint(x: 12, y: 24)]) + XCTAssertEqual(state.trace.directions, [.up]) + XCTAssertEqual(state.trace.directionEndpoints, [.zero, GesturePoint(x: 0, y: -20)]) + XCTAssertEqual(state.trace.tailPoint, GesturePoint(x: 12, y: 24)) + XCTAssertEqual( + partialTrace.directionEndpoints, + [.zero] + ) + } + + func testDocumentationExamplesAreUsableWithoutTestableImport() { + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.down, .right], match: .showInspector) + matcher.register(pattern: [.up, .left], match: .focusSearch) + + let recognizer = GestureRecognizer( + eventSource: PublicGestureEventSource(), + configuration: GestureRecognizerConfiguration( + makeMatcher: { _ in matcher }, + onReplayRequested: { _ in }, + onMatch: { _ in }, + areModifiersSatisfied: { modifiers, _ in + modifiers.contains(.command) + } + ) + ) + let traceToken = recognizer.observeTrace { trace in + renderTrace( + isVisible: trace.isVisible, + points: trace.rawPoints, + directions: trace.directions, + directionEndpoints: trace.directionEndpoints, + tailPoint: trace.tailPoint + ) + } + + XCTAssertEqual(matcher.match(pattern: [.down, .right]), .showInspector) + XCTAssertEqual(recognizer.snapshot.status.lifecycle, .idle) + traceToken.cancel() + } + +} + +private enum PublicGestureCommand: Sendable { + case showInspector + case focusSearch +} + +@MainActor +private final class PublicGestureEventSource: GestureEventSource { + func start( + handler: @escaping @MainActor @Sendable (GestureInputEvent) -> GestureEventDisposition + ) -> Bool { + _ = handler + return true + } + + func stop() {} +} + +@MainActor +private final class PublicObservationSource { + func observe() -> GestureObservationToken { + GestureRecognizer( + eventSource: PublicGestureEventSource(), + configuration: GestureRecognizerConfiguration( + makeMatcher: { _ in GesturePatternMatcher() }, + onReplayRequested: { _ in }, + onMatch: { _ in } + ) + ) + .observe { _ in } + } +} + +@MainActor +private func renderTrace( + isVisible: Bool, + points: [GesturePoint], + directions: [GestureDirection], + directionEndpoints: [GesturePoint], + tailPoint: GesturePoint? +) { + _ = isVisible + _ = points + _ = directions + _ = directionEndpoints + _ = tailPoint +} diff --git a/Tests/PointerGestureKitTests/PointerGestureKitSourceBoundaryTests.swift b/Tests/PointerGestureKitTests/PointerGestureKitSourceBoundaryTests.swift new file mode 100644 index 0000000..bfabaa6 --- /dev/null +++ b/Tests/PointerGestureKitTests/PointerGestureKitSourceBoundaryTests.swift @@ -0,0 +1,42 @@ +import XCTest + +final class PointerGestureKitSourceBoundaryTests: XCTestCase { + func testCoreTargetStaysPlatformNeutral() throws { + let source = try productionSource(for: "PointerGestureKit") + for forbiddenFragment in [ + "import AppKit", + "import Combine", + "import CoreGraphics", + "import SwiftUI", + "CGEvent", + "CGPoint", + "NSEvent", + "NSView", + "NSWindow", + ] { + XCTAssertFalse(source.contains(forbiddenFragment), "Unexpected \(forbiddenFragment) in core") + } + } + +} + +private func productionSource(for target: String) throws -> String { + let root = try packageRoot().appendingPathComponent("Sources/\(target)") + return try FileManager.default + .subpathsOfDirectory(atPath: root.path) + .filter { $0.hasSuffix(".swift") } + .sorted() + .map { try String(contentsOf: root.appendingPathComponent($0), encoding: .utf8) } + .joined(separator: "\n") +} + +private func packageRoot() throws -> URL { + var url = URL(fileURLWithPath: #filePath) + while url.path != "/" { + if FileManager.default.fileExists(atPath: url.appendingPathComponent("Package.swift").path) { + return url + } + url.deleteLastPathComponent() + } + throw XCTSkip("Package.swift not found") +} diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureMovementResolverTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureMovementResolverTests.swift deleted file mode 100644 index 453cbea..0000000 --- a/Tests/PointerGestureKitTests/Recognizer/GestureMovementResolverTests.swift +++ /dev/null @@ -1,103 +0,0 @@ -import XCTest - -@testable import PointerGestureKit - -final class GestureMovementResolverTests: XCTestCase { - func testMaximumAxisDeltaReturnsZeroWithoutMovement() { - XCTAssertEqual( - GestureMovementResolver.maximumAxisDelta( - from: GesturePoint(x: 10, y: 10), - to: GesturePoint(x: 10, y: 10) - ), - 0 - ) - } - - func testMaximumAxisDeltaReturnsLargerAbsoluteCoordinateDelta() { - XCTAssertEqual( - GestureMovementResolver.maximumAxisDelta( - from: GesturePoint(x: 10, y: 10), - to: GesturePoint(x: -5, y: 30) - ), - 20 - ) - XCTAssertEqual( - GestureMovementResolver.maximumAxisDelta( - from: GesturePoint(x: 10, y: 10), - to: GesturePoint(x: 35, y: 20) - ), - 25 - ) - } - - func testDominantDirectionResolvesPureCardinalMovement() { - assertDominantDirection(to: GesturePoint(x: 10, y: 0), is: .right) - assertDominantDirection(to: GesturePoint(x: -10, y: 0), is: .left) - assertDominantDirection(to: GesturePoint(x: 0, y: 10), is: .down) - assertDominantDirection(to: GesturePoint(x: 0, y: -10), is: .up) - } - - func testZeroDeltaHasNoDirection() { - assertNoDominantDirection(to: .zero) - assertNoDominantDirection(from: GesturePoint(x: 10, y: 10), to: GesturePoint(x: 10, y: 10)) - } - - func testHorizontalDominantMovementResolvesHorizontally() { - assertDominantDirection(to: GesturePoint(x: 10, y: 9), is: .right) - assertDominantDirection(to: GesturePoint(x: -10, y: 9), is: .left) - assertDominantDirection(to: GesturePoint(x: 10, y: -9), is: .right) - assertDominantDirection(to: GesturePoint(x: -10, y: -9), is: .left) - } - - func testEqualAxisMovementHasNoDominantDirection() { - assertNoDominantDirection(to: GesturePoint(x: 10, y: 10)) - assertNoDominantDirection(to: GesturePoint(x: -10, y: 10)) - assertNoDominantDirection(to: GesturePoint(x: 10, y: -10)) - assertNoDominantDirection(to: GesturePoint(x: -10, y: -10)) - } - - func testVerticalDominantMovementResolvesVertically() { - assertDominantDirection(to: GesturePoint(x: 10, y: 25), is: .down) - assertDominantDirection(to: GesturePoint(x: 10, y: -25), is: .up) - assertDominantDirection(to: GesturePoint(x: -10, y: 25), is: .down) - assertDominantDirection(to: GesturePoint(x: -10, y: -25), is: .up) - } - - func testDominantDirectionUsesStartPoint() { - XCTAssertEqual( - GestureMovementResolver.dominantDirection( - from: GesturePoint(x: 100, y: 100), - to: GesturePoint(x: 75, y: 110) - ), - .left - ) - } - - private func assertDominantDirection( - from start: GesturePoint = .zero, - to end: GesturePoint, - is expectedDirection: GestureDirection, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertEqual( - GestureMovementResolver.dominantDirection(from: start, to: end), - expectedDirection, - file: file, - line: line - ) - } - - private func assertNoDominantDirection( - from start: GesturePoint = .zero, - to end: GesturePoint, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertNil( - GestureMovementResolver.dominantDirection(from: start, to: end), - file: file, - line: line - ) - } -} diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureObservationTokenTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureObservationTokenTests.swift deleted file mode 100644 index 6c15cd7..0000000 --- a/Tests/PointerGestureKitTests/Recognizer/GestureObservationTokenTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation -import XCTest - -@testable import PointerGestureKit - -@MainActor -final class GestureObservationTokenTests: XCTestCase { - func testCancelOnMainThreadRunsHandlerSynchronouslyOnce() { - var cancelCount = 0 - var cancelThreadWasMain = false - let token = GestureObservationToken(onCancel: { - cancelCount += 1 - cancelThreadWasMain = Thread.isMainThread - }) - - XCTAssertEqual(cancelCount, 0) - token.cancel() - XCTAssertEqual(cancelCount, 1) - XCTAssertTrue(cancelThreadWasMain) - - token.cancel() - XCTAssertEqual(cancelCount, 1) - } - - func testDeinitCancelsActiveToken() { - var cancelCount = 0 - var token: GestureObservationToken? = GestureObservationToken(onCancel: { - cancelCount += 1 - }) - - XCTAssertNotNil(token) - XCTAssertEqual(cancelCount, 0) - token = nil - XCTAssertEqual(cancelCount, 1) - } - - func testDeinitFromAnotherActorCancelsActiveTokenOnMainThread() async { - let probe = ObservationCancellationProbe() - - await Task.detached { @Sendable [probe] in - let token = await MainActor.run { - probe.makeToken() - } - _ = token - }.value - - await XCTAssertEventually { probe.cancelCount == 1 } - XCTAssertTrue(probe.cancelThreadWasMain) - } - - func testDeinitAfterCancelDoesNotCancelAgain() { - var cancelCount = 0 - var token: GestureObservationToken? = GestureObservationToken(onCancel: { - cancelCount += 1 - }) - - token?.cancel() - XCTAssertEqual(cancelCount, 1) - - token = nil - XCTAssertEqual(cancelCount, 1) - } - - func testCancelFromAnotherActorCancelsOnceOnMainThread() async { - var cancelCount = 0 - var cancelThreadWasMain = false - let token = GestureObservationToken(onCancel: { - cancelCount += 1 - cancelThreadWasMain = Thread.isMainThread - }) - - await Task.detached { - token.cancel() - }.value - await XCTAssertEventually { cancelCount == 1 } - XCTAssertTrue(cancelThreadWasMain) - - token.cancel() - XCTAssertEqual(cancelCount, 1) - } - - func testSendableContractAcceptsToken() { - let token = GestureObservationToken(onCancel: {}) - - assertSendable(token) - } - - func testDoesNotExposeValueSerializationOrEnumerationContracts() { - XCTAssertFalse(GestureObservationToken.self is any Equatable.Type) - XCTAssertFalse(GestureObservationToken.self is any Hashable.Type) - XCTAssertFalse(GestureObservationToken.self is any Codable.Type) - XCTAssertFalse(GestureObservationToken.self is any RawRepresentable.Type) - XCTAssertFalse(GestureObservationToken.self is any CaseIterable.Type) - XCTAssertFalse(GestureObservationToken.self is any Error.Type) - } -} - -@MainActor -private final class ObservationCancellationProbe { - private(set) var cancelCount = 0 - private(set) var cancelThreadWasMain = false - - func makeToken() -> GestureObservationToken { - GestureObservationToken(onCancel: { - self.cancelCount += 1 - self.cancelThreadWasMain = Thread.isMainThread - }) - } -} diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognitionContextTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognitionContextTests.swift index 83471d4..8ac4d06 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognitionContextTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognitionContextTests.swift @@ -25,38 +25,28 @@ final class GestureRecognitionContextTests: XCTestCase { XCTAssertEqual(context.identifier, "host context") } - func testEqualityUsesIdentifier() throws { - let context = try XCTUnwrap(GestureRecognitionContext(identifier: "host.context")) - let same = try XCTUnwrap(GestureRecognitionContext(identifier: "host.context")) - let different = try XCTUnwrap(GestureRecognitionContext(identifier: "other.context")) - - XCTAssertEqual(context, same) - XCTAssertNotEqual(context, different) - } - func testEqualityUsesNormalizedIdentifier() throws { XCTAssertEqual( try XCTUnwrap(GestureRecognitionContext(identifier: " host.context ")), try XCTUnwrap(GestureRecognitionContext(identifier: "host.context")) ) + XCTAssertNotEqual( + try XCTUnwrap(GestureRecognitionContext(identifier: "host.context")), + try XCTUnwrap(GestureRecognitionContext(identifier: "other.context")) + ) + } + + func testSendableContractAcceptsContext() throws { + assertSendable(try XCTUnwrap(GestureRecognitionContext(identifier: "host.context"))) } - func testHashableContractSupportsCollections() throws { + func testEquatableContractUsesIdentifier() throws { let context = try XCTUnwrap(GestureRecognitionContext(identifier: " host.context ")) let same = try XCTUnwrap(GestureRecognitionContext(identifier: "host.context")) let different = try XCTUnwrap(GestureRecognitionContext(identifier: "other.context")) - XCTAssertEqual(Set([context, same, different]), [same, different]) - } - - func testSendableContractAcceptsContext() throws { - assertSendable(try XCTUnwrap(GestureRecognitionContext(identifier: "host.context"))) + XCTAssertEqual(context, same) + XCTAssertNotEqual(context, different) } - func testDoesNotExposeSerializationOrEnumerationContracts() { - XCTAssertFalse(GestureRecognitionContext.self is any Codable.Type) - XCTAssertFalse(GestureRecognitionContext.self is any RawRepresentable.Type) - XCTAssertFalse(GestureRecognitionContext.self is any CaseIterable.Type) - XCTAssertFalse(GestureRecognitionContext.self is any Error.Type) - } } diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerBasicUsageTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerBasicUsageTests.swift index 7ad5a33..cac4e4e 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerBasicUsageTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerBasicUsageTests.swift @@ -31,17 +31,18 @@ final class GestureRecognizerBasicUsageTests: XCTestCase { } recognizer.start() - XCTAssertEqual(send(.buttonDown(.secondary), at: .zero), .consume) - XCTAssertEqual(send(.buttonDragged(.secondary), at: .init(x: 0, y: 40)), .consume) - XCTAssertEqual(send(.buttonDragged(.secondary), at: .init(x: 40, y: 40)), .consume) - XCTAssertEqual(send(.buttonUp(.secondary), at: .init(x: 40, y: 40)), .consume) + assertDisposition(send(.buttonDown(.secondary), at: .zero), .consume) + assertDisposition(send(.buttonMoved(.secondary), at: .init(x: 0, y: 40)), .consume) + assertDisposition(send(.buttonMoved(.secondary), at: .init(x: 40, y: 40)), .consume) + assertDisposition(send(.buttonUp(.secondary), at: .init(x: 40, y: 40)), .consume) XCTAssertEqual(matchedCommands, [.showInspector]) - XCTAssertEqual(replayRequests, [.release(button: .secondary, at: .init(x: 40, y: 40))]) + assertReplayRequests(replayRequests, [.release(button: .secondary, at: .init(x: 40, y: 40))]) } - func testRecognizerPublicContractIsReferenceOwnedAndSendable() { + func testRecognizerIsReferenceOwnedAndSendableFromPublicUse() { let eventSource = GestureEventSourceDouble(startResult: true) + let eventSourceContract: any GestureEventSource = eventSource let recognizer = GestureRecognizer( eventSource: eventSource, configuration: GestureRecognizerConfiguration( @@ -51,13 +52,32 @@ final class GestureRecognizerBasicUsageTests: XCTestCase { ) ) + XCTAssertTrue(eventSourceContract === eventSource) assertSendable(recognizer) - XCTAssertFalse(GestureRecognizer.self is any Equatable.Type) - XCTAssertFalse(GestureRecognizer.self is any Hashable.Type) - XCTAssertFalse(GestureRecognizer.self is any Codable.Type) - XCTAssertFalse(GestureRecognizer.self is any RawRepresentable.Type) - XCTAssertFalse(GestureRecognizer.self is any CaseIterable.Type) - XCTAssertFalse(GestureRecognizer.self is any Error.Type) + } + + func testRecognizerPassesThroughNonFiniteInputWithoutStartingState() { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: GestureRecognizerConfiguration( + makeMatcher: { _ in Self.makeGestureMatcher() }, + onReplayRequested: { _ in }, + onMatch: { _ in } + ) + ) + + recognizer.start() + let disposition = eventSource.send( + makeGestureInputEvent( + kind: .buttonDown(.secondary), + location: GesturePoint(x: .nan, y: 10) + ) + ) + + assertDisposition(disposition, .passThrough) + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) } private static func makeGestureMatcher() -> GesturePatternMatcher { diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerButtonClickTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerButtonClickTests.swift index ccbeee1..d37d528 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerButtonClickTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerButtonClickTests.swift @@ -4,7 +4,7 @@ import PointerGestureKit @MainActor final class GestureRecognizerButtonClickTests: XCTestCase { - func testMovedPendingButtonInputRequestsSequenceReplayWithoutStartingGesture() { + func testMovedPendingButtonInputBelowStartDistanceRequestsClickReplayForContextMenu() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -25,33 +25,75 @@ final class GestureRecognizerButtonClickTests: XCTestCase { recognizer.start() - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ), .consume ) - XCTAssertEqual( + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 28, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 19, y: 10)) ), .consume ) - XCTAssertEqual( + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 28, y: 10)) + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 19, y: 10)) ), .consume ) - XCTAssertEqual( + assertReplayRequests( replayRequests, - [.drag(button: .secondary, points: [.init(x: 10, y: 10), .init(x: 28, y: 10)])] + [.click(button: .secondary, at: .init(x: 10, y: 10))] ) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertFalse(recognizer.snapshot.trace.isVisible) } + func testMovementAtMinimumStartDistanceStartsRecognizedGesture() { + let eventSource = GestureEventSourceDouble(startResult: true) + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: UUID()) + return matcher + }, + tuning: .testing( + minimumGestureStartAxisDistance: 10, + minimumDirectionChangeAxisDistance: 0 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + recognizer.start() + + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ), + .consume + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 10, y: 0)) + ), + .consume + ) + + XCTAssertTrue(recognizer.snapshot.status.isCapturingGesture) + XCTAssertTrue(recognizer.snapshot.trace.isVisible) + XCTAssertEqual(recognizer.snapshot.trace.directions, [.right]) + XCTAssertEqual(recognizer.snapshot.trace.directionEndpoints, [.zero, .init(x: 10, y: 0)]) + } + func testPlainButtonClickRequestsClickReplayAtStartPoint() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -73,7 +115,7 @@ final class GestureRecognizerButtonClickTests: XCTestCase { recognizer.start() - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ), @@ -81,13 +123,13 @@ final class GestureRecognizerButtonClickTests: XCTestCase { ) XCTAssertTrue(replayRequests.isEmpty) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 10, y: 10)) ), .consume ) - XCTAssertEqual(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) + assertReplayRequests(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertFalse(recognizer.snapshot.trace.isVisible) } @@ -113,20 +155,20 @@ final class GestureRecognizerButtonClickTests: XCTestCase { recognizer.start() - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ), .consume ) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 12, y: 11)) ), .consume ) - XCTAssertEqual(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) + assertReplayRequests(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertFalse(recognizer.snapshot.trace.isVisible) } @@ -155,24 +197,25 @@ final class GestureRecognizerButtonClickTests: XCTestCase { recognizer.start() - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) ), .consume ) - XCTAssertEqual( + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 20, y: 0)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 20, y: 0)) ), .consume ) XCTAssertTrue(recognizer.snapshot.trace.isVisible) XCTAssertEqual(recognizer.snapshot.trace.rawPoints, [.init(x: 20, y: 0)]) + XCTAssertEqual(recognizer.snapshot.trace.directionEndpoints, [.zero, .init(x: 20, y: 0)]) } - func testRepeatedButtonDownKeepsPendingButtonInputPoint() { + func testRepeatedButtonDownReplaysPendingInputAndStartsNewInput() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] var recognitionContextRequestCount = 0 @@ -194,27 +237,87 @@ final class GestureRecognizerButtonClickTests: XCTestCase { recognizer.start() - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ), .consume ) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 20, y: 20)) ), .consume ) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 20, y: 20)) ), .consume ) - XCTAssertEqual(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) - XCTAssertEqual(recognitionContextRequestCount, 1) + assertReplayRequests( + replayRequests, + [ + .click(button: .secondary, at: .init(x: 10, y: 10)), + .click(button: .secondary, at: .init(x: 20, y: 20)), + ] + ) + XCTAssertEqual(recognitionContextRequestCount, 2) + } + + func testRepeatedButtonDownReleasesActiveSessionAndStartsNewInput() { + let eventSource = GestureEventSourceDouble(startResult: true) + var replayRequests: [GestureReplayRequest] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + onReplayRequested: { replayRequests.append($0) }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 100 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + recognizer.isRecordingModeEnabled = true + recognizer.start() + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10)) + ) + XCTAssertTrue(recognizer.snapshot.status.isCapturingGesture) + + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 80, y: 80)) + ), + .consume + ) + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 80, y: 80)) + ), + .consume + ) + + assertReplayRequests( + replayRequests, + [ + .release(button: .secondary, at: .init(x: 40, y: 10)), + .click(button: .secondary, at: .init(x: 80, y: 80)), + ] + ) } } diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerCancelEventTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerCancelEventTests.swift index 6d7faeb..50a9c5f 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerCancelEventTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerCancelEventTests.swift @@ -21,21 +21,21 @@ final class GestureRecognizerCancelEventTests: XCTestCase { recognizer.start() - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ), .consume ) - XCTAssertEqual(eventSource.send(makeCancelGestureInputEvent()), .consume) - XCTAssertEqual( + assertDisposition(eventSource.send(makeCancelGestureInputEvent()), .consume) + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 10, y: 10)) ), .passThrough ) - XCTAssertEqual(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) + assertReplayRequests(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertFalse(recognizer.snapshot.trace.isVisible) } @@ -72,21 +72,21 @@ final class GestureRecognizerCancelEventTests: XCTestCase { )) _ = eventSource.send( makeGestureInputEvent( - kind: .buttonDragged(.secondary), + kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10), modifiers: [.command] )) XCTAssertTrue(recognizer.snapshot.status.isCapturingGesture) let result = eventSource.send(makeCancelGestureInputEvent()) - XCTAssertEqual(result, .consume) - XCTAssertEqual(replayRequests, [.release(button: .secondary, at: .init(x: 40, y: 10))]) + assertDisposition(result, .consume) + assertReplayRequests(replayRequests, [.release(button: .secondary, at: .init(x: 40, y: 10))]) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertFalse(recognizer.snapshot.trace.isVisible) XCTAssertTrue(recognizer.snapshot.trace.rawPoints.isEmpty) } - func testCancelEventReplaysStoppedSessionAwaitingReplay() { + func testCancelEventReplaysUnmatchedActiveSessionAsConsumedDrag() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -114,26 +114,27 @@ final class GestureRecognizerCancelEventTests: XCTestCase { _ = eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10))) - XCTAssertEqual( + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 10, y: 60)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 10, y: 60)) ), .consume ) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertFalse(recognizer.snapshot.trace.isVisible) + XCTAssertTrue(recognizer.snapshot.trace.directions.isEmpty) - XCTAssertEqual(eventSource.send(makeCancelGestureInputEvent()), .consume) - XCTAssertEqual( + assertDisposition(eventSource.send(makeCancelGestureInputEvent()), .passThrough) + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 10, y: 60)) ), .passThrough ) - XCTAssertEqual( + assertReplayRequests( replayRequests, - [.drag(button: .secondary, points: [.init(x: 10, y: 10), .init(x: 10, y: 60)])] + [.dragStart(button: .secondary, points: [.init(x: 10, y: 10), .init(x: 10, y: 60)])] ) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertFalse(recognizer.snapshot.trace.isVisible) @@ -154,6 +155,6 @@ final class GestureRecognizerCancelEventTests: XCTestCase { recognizer.start() let result = eventSource.send(makeCancelGestureInputEvent()) - XCTAssertEqual(result, .passThrough) + assertDisposition(result, .passThrough) } } diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerCancellationTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerCancellationTests.swift index f30bd44..2a8fe03 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerCancellationTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerCancellationTests.swift @@ -53,13 +53,13 @@ final class GestureRecognizerCancellationTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 40, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10)) ) XCTAssertTrue(recognizer.snapshot.status.isCapturingGesture) recognizer.cancelActiveGesture() - XCTAssertEqual(replayRequests, [.release(button: .secondary, at: .init(x: 40, y: 10))]) + assertReplayRequests(replayRequests, [.release(button: .secondary, at: .init(x: 40, y: 10))]) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) } @@ -85,17 +85,17 @@ final class GestureRecognizerCancellationTests: XCTestCase { recognizer.cancelActiveGesture() - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 10, y: 10)) ), .passThrough ) - XCTAssertEqual(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) + assertReplayRequests(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) } - func testCancelActiveGestureReplaysMovedPendingButtonInput() { + func testCancelActiveGestureReplaysMovedPendingButtonInputBelowStartDistanceAsClick() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -119,20 +119,20 @@ final class GestureRecognizerCancellationTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 20, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 19, y: 10)) ) recognizer.cancelActiveGesture() - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 20, y: 10)) ), .passThrough ) - XCTAssertEqual( + assertReplayRequests( replayRequests, - [.drag(button: .secondary, points: [.init(x: 10, y: 10), .init(x: 20, y: 10)])] + [.click(button: .secondary, at: .init(x: 10, y: 10))] ) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) } @@ -166,7 +166,7 @@ final class GestureRecognizerCancellationTests: XCTestCase { XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 40, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10)) ) XCTAssertTrue(recognizer.snapshot.trace.isVisible) @@ -177,6 +177,49 @@ final class GestureRecognizerCancellationTests: XCTestCase { XCTAssertTrue(recognizer.snapshot.trace.rawPoints.isEmpty) } + func testCancelActiveGestureHidesImmediatelyAfterVisibleExactMatchTrace() async { + let eventSource = GestureEventSourceDouble(startResult: true) + var traceVisibility: [Bool] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: UUID()) + return matcher + }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + let observation = recognizer.observeTrace { trace in + traceVisibility.append(trace.isVisible) + } + defer { observation.cancel() } + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10)) + ) + + recognizer.cancelActiveGesture() + try? await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertEqual(traceVisibility, [false, true, false]) + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + } + func testCancelActiveGesturePreservesLastFailureWhenNoGestureIsActive() { let eventSource = GestureEventSourceDouble(startResult: true) @@ -223,5 +266,4 @@ final class GestureRecognizerCancellationTests: XCTestCase { XCTAssertEqual(recognizer.snapshot.status.lifecycle, .failed) XCTAssertEqual(recognizer.snapshot.status.lastFailure, .eventSourceStartFailed) } - } diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerConfigurationTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerConfigurationTests.swift index 1967d47..e8d726f 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerConfigurationTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerConfigurationTests.swift @@ -18,6 +18,43 @@ final class GestureRecognizerConfigurationTests: XCTestCase { XCTAssertEqual(recognizer.snapshot.status.lifecycle, .ready) } + func testDefaultPoliciesAllowSecondaryGestureWithoutContextOrModifiers() { + let eventSource = GestureEventSourceDouble(startResult: true) + var matchedID: UUID? + var replayRequests: [GestureReplayRequest] = [] + let id = UUID() + let configuration = GestureRecognizerConfiguration( + makeMatcher: { context in + XCTAssertNil(context) + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: id) + return matcher + }, + onReplayRequested: { replayRequests.append($0) }, + onMatch: { matchedID = $0 } + ) + let recognizer = GestureRecognizer(eventSource: eventSource, configuration: configuration) + + recognizer.start() + assertDisposition( + eventSource.send(makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero)), + .consume + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0))), + .consume + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 40, y: 0))), + .consume + ) + + XCTAssertEqual(matchedID, id) + assertReplayRequests(replayRequests, [.release(button: .secondary, at: .init(x: 40, y: 0))]) + } + func testConstructedConfigurationDrivesRecognition() throws { let context = try XCTUnwrap(GestureRecognitionContext(identifier: "custom")) var clickPoint: GesturePoint? @@ -39,7 +76,7 @@ final class GestureRecognizerConfigurationTests: XCTestCase { switch request { case let .click(_, point): clickPoint = point - case .drag: + case .drag, .dragStart: XCTFail("Matched gestures should not replay the consumed sequence.") case let .release(_, point): releasePoint = point @@ -52,12 +89,9 @@ final class GestureRecognizerConfigurationTests: XCTestCase { modifierPolicyContext = receivedContext return receivedContext == context && modifiers.contains(.command) }, - tuning: GestureRecognizerTuning( - minimumGestureStartAxisDistance: 1, - minimumDirectionChangeAxisDistance: 2, - maximumGestureSessionDuration: 3, - maximumRawPointCount: 25, - eventSourceStartRetryDelays: [0] + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 ) ) let recognizer = GestureRecognizer(eventSource: eventSource, configuration: configuration) @@ -66,7 +100,7 @@ final class GestureRecognizerConfigurationTests: XCTestCase { _ = eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero, modifiers: [.command])) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: GesturePoint(x: 10, y: 0))) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: GesturePoint(x: 10, y: 0))) _ = eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: GesturePoint(x: 20, y: 0))) @@ -77,89 +111,12 @@ final class GestureRecognizerConfigurationTests: XCTestCase { XCTAssertEqual(matchedID, id) } - func testRecognitionButtonSelectsWhichPointerButtonCanStartGestures() { - let eventSource = GestureEventSourceDouble(startResult: true) - var replayRequests: [GestureReplayRequest] = [] - let configuration: GestureRecognizerConfiguration = - makeGestureRecognizerTestConfiguration( - onReplayRequested: { replayRequests.append($0) }, - recognitionButton: .primary, - tuning: .testing(minimumGestureStartAxisDistance: 0) - ) - let recognizer = GestureRecognizer(eventSource: eventSource, configuration: configuration) - - recognizer.start() - XCTAssertEqual( - eventSource.send(makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero)), - .passThrough - ) - XCTAssertEqual( - eventSource.send(makeGestureInputEvent(kind: .buttonDown(.primary), location: .zero)), - .consume - ) - XCTAssertEqual( - eventSource.send(makeGestureInputEvent(kind: .buttonUp(.primary), location: .zero)), - .consume - ) - - XCTAssertEqual(replayRequests, [.click(button: .primary, at: .zero)]) - } - - func testRecognitionButtonSupportsMiddlePointerButton() { - let eventSource = GestureEventSourceDouble(startResult: true) - var replayRequests: [GestureReplayRequest] = [] - let configuration: GestureRecognizerConfiguration = - makeGestureRecognizerTestConfiguration( - onReplayRequested: { replayRequests.append($0) }, - recognitionButton: .middle, - tuning: .testing(minimumGestureStartAxisDistance: 0) - ) - let recognizer = GestureRecognizer(eventSource: eventSource, configuration: configuration) - - recognizer.start() - XCTAssertEqual( - eventSource.send(makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero)), - .passThrough - ) - XCTAssertEqual( - eventSource.send(makeGestureInputEvent(kind: .buttonDown(.middle), location: .zero)), - .consume - ) - XCTAssertEqual( - eventSource.send(makeGestureInputEvent(kind: .buttonUp(.middle), location: .zero)), - .consume - ) - - XCTAssertEqual(replayRequests, [.click(button: .middle, at: .zero)]) - } - - func testRecognitionButtonSupportsAdditionalPointerButton() throws { - let additionalButton = try XCTUnwrap(PointerButton(additionalButtonNumber: 4)) - let eventSource = GestureEventSourceDouble(startResult: true) - var replayRequests: [GestureReplayRequest] = [] - let configuration: GestureRecognizerConfiguration = - makeGestureRecognizerTestConfiguration( - onReplayRequested: { replayRequests.append($0) }, - recognitionButton: additionalButton, - tuning: .testing(minimumGestureStartAxisDistance: 0) - ) - let recognizer = GestureRecognizer(eventSource: eventSource, configuration: configuration) - - recognizer.start() - XCTAssertEqual( - eventSource.send(makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero)), - .passThrough - ) - XCTAssertEqual( - eventSource.send(makeGestureInputEvent(kind: .buttonDown(additionalButton), location: .zero)), - .consume - ) - XCTAssertEqual( - eventSource.send(makeGestureInputEvent(kind: .buttonUp(additionalButton), location: .zero)), - .consume - ) + func testRecognitionButtonSelectsWhichPointerButtonCanStartGestures() throws { + let auxiliaryButton = try XCTUnwrap(PointerButton(auxiliaryButtonID: 4)) - XCTAssertEqual(replayRequests, [.click(button: additionalButton, at: .zero)]) + assertRecognitionButtonStartsGesture(.primary) + assertRecognitionButtonStartsGesture(.middle) + assertRecognitionButtonStartsGesture(auxiliaryButton) } func testSendableContractAcceptsConfiguration() { @@ -172,12 +129,43 @@ final class GestureRecognizerConfigurationTests: XCTestCase { assertSendable(configuration) } - func testDoesNotExposeValueSerializationOrEnumerationContracts() { - XCTAssertFalse(GestureRecognizerConfiguration.self is any Equatable.Type) - XCTAssertFalse(GestureRecognizerConfiguration.self is any Hashable.Type) - XCTAssertFalse(GestureRecognizerConfiguration.self is any Codable.Type) - XCTAssertFalse(GestureRecognizerConfiguration.self is any RawRepresentable.Type) - XCTAssertFalse(GestureRecognizerConfiguration.self is any CaseIterable.Type) - XCTAssertFalse(GestureRecognizerConfiguration.self is any Error.Type) - } +} + +@MainActor +private func assertRecognitionButtonStartsGesture( + _ button: PointerButton, + file: StaticString = #filePath, + line: UInt = #line +) { + let eventSource = GestureEventSourceDouble(startResult: true) + var replayRequests: [GestureReplayRequest] = [] + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + onReplayRequested: { replayRequests.append($0) }, + recognitionButton: button, + tuning: .testing(minimumGestureStartAxisDistance: 0) + ) + let recognizer = GestureRecognizer(eventSource: eventSource, configuration: configuration) + + recognizer.start() + assertDisposition( + eventSource.send(makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero)), + .passThrough, + file: file, + line: line + ) + assertDisposition( + eventSource.send(makeGestureInputEvent(kind: .buttonDown(button), location: .zero)), + .consume, + file: file, + line: line + ) + assertDisposition( + eventSource.send(makeGestureInputEvent(kind: .buttonUp(button), location: .zero)), + .consume, + file: file, + line: line + ) + + assertReplayRequests(replayRequests, [.click(button: button, at: .zero)], file: file, line: line) } diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerContextPolicyTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerContextPolicyTests.swift index feae983..b30badd 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerContextPolicyTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerContextPolicyTests.swift @@ -46,7 +46,7 @@ final class GestureRecognizerContextPolicyTests: XCTestCase { let configuration: GestureRecognizerConfiguration = makeGestureRecognizerTestConfiguration( onReplayRequested: { request in - if case let .drag(_, points) = request { + if case let .dragStart(_, points) = request { sequenceReplayRequests.append(points) } }, @@ -69,20 +69,20 @@ final class GestureRecognizerContextPolicyTests: XCTestCase { recognizer.start() let down = makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) - XCTAssertEqual(eventSource.send(down), .consume) + assertDisposition(eventSource.send(down), .consume) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) currentContext.value = disabledContext let drag = makeGestureInputEvent( - kind: .buttonDragged(.secondary), location: .init(x: 100, y: 10)) - XCTAssertEqual(eventSource.send(drag), .consume) + kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) + assertDisposition(eventSource.send(drag), .consume) let up = makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 120, y: 10)) - XCTAssertEqual(eventSource.send(up), .consume) + assertDisposition(eventSource.send(up), .passThrough) XCTAssertEqual( sequenceReplayRequests, - [[.init(x: 10, y: 10), .init(x: 100, y: 10), .init(x: 120, y: 10)]] + [[.init(x: 10, y: 10), .init(x: 100, y: 10)]] ) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertFalse(recognizer.snapshot.trace.isVisible) @@ -111,7 +111,7 @@ final class GestureRecognizerContextPolicyTests: XCTestCase { let down = makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) let result = eventSource.send(down) - XCTAssertEqual(result, .passThrough) + assertDisposition(result, .passThrough) XCTAssertEqual(recognizer.snapshot.status.lastFailure, .recognitionDisabled) } @@ -138,7 +138,7 @@ final class GestureRecognizerContextPolicyTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) - XCTAssertEqual(result, .passThrough) + assertDisposition(result, .passThrough) XCTAssertEqual(recognizer.snapshot.status.lastFailure, .recognitionDisabled) } @@ -167,7 +167,7 @@ final class GestureRecognizerContextPolicyTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) - XCTAssertEqual(result, .passThrough) + assertDisposition(result, .passThrough) XCTAssertEqual(recognizer.snapshot.status.lastFailure, .recognitionDisabled) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) } @@ -191,13 +191,13 @@ final class GestureRecognizerContextPolicyTests: XCTestCase { recognizer.start() - XCTAssertEqual( + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 20, y: 20)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 20, y: 20)) ), .passThrough ) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 20, y: 20)) ), @@ -241,7 +241,7 @@ final class GestureRecognizerContextPolicyTests: XCTestCase { kind: .buttonDown(.secondary), location: .init(x: 10, y: 10) )) - XCTAssertEqual(disabledResult, .passThrough) + assertDisposition(disabledResult, .passThrough) XCTAssertEqual(recognizer.snapshot.status.lastFailure, .recognitionDisabled) currentContext.value = enabledContext @@ -250,7 +250,7 @@ final class GestureRecognizerContextPolicyTests: XCTestCase { kind: .buttonDown(.secondary), location: .init(x: 20, y: 20) )) - XCTAssertEqual(enabledResult, .consume) + assertDisposition(enabledResult, .consume) XCTAssertNil(recognizer.snapshot.status.lastFailure) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertTrue(clickRequestPoints.isEmpty) diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerDeinitTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerDeinitTests.swift index a27cf07..cfe2cb5 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerDeinitTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerDeinitTests.swift @@ -34,18 +34,18 @@ final class GestureRecognizerDeinitTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 40, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10)) ) XCTAssertTrue(recognizer.snapshot.status.isCapturingGesture) } - XCTAssertEqual(replayRequests, [.release(button: .secondary, at: .init(x: 40, y: 10))]) + assertReplayRequests(replayRequests, [.release(button: .secondary, at: .init(x: 40, y: 10))]) XCTAssertEqual(stopCountWhenReplayWasRequested, 0) XCTAssertEqual(eventSource.stopCount, 1) } - func testDeinitReplaysMovedPendingButtonInput() { + func testDeinitReplaysMovedPendingButtonInputBelowStartDistanceAsClick() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] var stopCountWhenReplayWasRequested: Int? @@ -74,15 +74,15 @@ final class GestureRecognizerDeinitTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 20, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 19, y: 10)) ) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) } - XCTAssertEqual( + assertReplayRequests( replayRequests, - [.drag(button: .secondary, points: [.init(x: 10, y: 10), .init(x: 20, y: 10)])] + [.click(button: .secondary, at: .init(x: 10, y: 10))] ) XCTAssertEqual(stopCountWhenReplayWasRequested, 0) XCTAssertEqual(eventSource.stopCount, 1) @@ -102,6 +102,7 @@ final class GestureRecognizerDeinitTests: XCTestCase { XCTAssertEqual(probe.stopCountWhenReplayWasRequested, 0) XCTAssertEqual(probe.eventSource.stopCount, 1) } + } @MainActor @@ -135,7 +136,7 @@ private final class GestureRecognizerDeinitProbe { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 40, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10)) ) XCTAssertTrue(recognizer.snapshot.status.isCapturingGesture) diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerMatchingTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerMatchingTests.swift index 37c66f2..9888232 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerMatchingTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerMatchingTests.swift @@ -4,6 +4,38 @@ import PointerGestureKit @MainActor final class GestureRecognizerMatchingTests: XCTestCase { + func testEmptyMatcherPassesThroughRecognitionButtonInput() { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: makeGestureRecognizerTestConfiguration(passesThroughEmptyMatcher: true) + ) + + recognizer.start() + + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) + ), + .passThrough + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10)) + ), + .passThrough + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 40, y: 10)) + ), + .passThrough + ) + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + XCTAssertTrue(recognizer.snapshot.trace.directions.isEmpty) + } + func testMatchCallsOnMatchAndConsumesButtonUp() { let eventSource = GestureEventSourceDouble(startResult: true) var releaseRequests: [GesturePoint] = [] @@ -25,7 +57,7 @@ final class GestureRecognizerMatchingTests: XCTestCase { switch request { case .click: clickRequestCount += 1 - case .drag: + case .drag, .dragStart: XCTFail("Matched gestures should not replay the consumed sequence.") case let .release(_, point): releaseRequests.append(point) @@ -50,18 +82,252 @@ final class GestureRecognizerMatchingTests: XCTestCase { _ = eventSource.send(down) let drag = makeGestureInputEvent( - kind: .buttonDragged(.secondary), location: .init(x: 100, y: 10)) - XCTAssertEqual(eventSource.send(drag), .consume) + kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) + assertDisposition(eventSource.send(drag), .consume) let up = makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 120, y: 10)) let result = eventSource.send(up) - XCTAssertEqual(result, .consume) + assertDisposition(result, .consume) XCTAssertEqual(calledIDs, [matchedID]) XCTAssertEqual(clickRequestCount, 0) XCTAssertEqual(releaseRequests, [.init(x: 120, y: 10)]) } + func testMatchedGestureRequestsReplayBeforeMatchCallback() { + let eventSource = GestureEventSourceDouble(startResult: true) + let matchedID = UUID() + var callbacks: [String] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: matchedID) + return matcher + }, + onReplayRequested: { _ in callbacks.append("replay") }, + onMatch: { _ in callbacks.append("match") }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) + ), + .consume + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 120, y: 10)) + ), + .consume + ) + + XCTAssertEqual(callbacks, ["replay", "match"]) + } + + func testRecognitionSessionKeepsReleaseOnlyReplayWhenRecordingModeIsEnabledMidGesture() { + let eventSource = GestureEventSourceDouble(startResult: true) + let matchedID = UUID() + var calledIDs: [UUID] = [] + var replayRequests: [GestureReplayRequest] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: matchedID) + return matcher + }, + onReplayRequested: { replayRequests.append($0) }, + onMatch: { calledIDs.append($0) }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) + ) + + recognizer.isRecordingModeEnabled = true + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 120, y: 10)) + ) + + XCTAssertEqual(calledIDs, [matchedID]) + assertReplayRequests(replayRequests, [.release(button: .secondary, at: .init(x: 120, y: 10))]) + } + + func testFirstDirectionUsesGestureStartThreshold() { + let eventSource = GestureEventSourceDouble(startResult: true) + let matchedID = UUID() + var calledIDs: [UUID] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: matchedID) + return matcher + }, + onMatch: { calledIDs.append($0) }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 10, + minimumDirectionChangeAxisDistance: 25 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 21, y: 10)) + ), + .consume + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 21, y: 10)) + ), + .consume + ) + + XCTAssertEqual(calledIDs, [matchedID]) + } + + func testMovementWithoutDominantAxisDoesNotStartGesture() { + let eventSource = GestureEventSourceDouble(startResult: true) + var replayRequests: [GestureReplayRequest] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: UUID()) + return matcher + }, + onReplayRequested: { replayRequests.append($0) }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 10, + minimumDirectionChangeAxisDistance: 0 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 30, y: 30)) + ), + .consume + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 30, y: 30)) + ), + .consume + ) + + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertTrue(recognizer.snapshot.trace.directions.isEmpty) + assertReplayRequests( + replayRequests, + [ + .drag( + button: .secondary, + points: [ + .init(x: 10, y: 10), + .init(x: 30, y: 30), + ] + ) + ] + ) + } + + func testRegisteringSamePatternUsesLatestMatch() { + let eventSource = GestureEventSourceDouble(startResult: true) + let firstID = UUID() + let secondID = UUID() + var calledIDs: [UUID] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: firstID) + matcher.register(pattern: [.right], match: secondID) + return matcher + }, + onMatch: { calledIDs.append($0) }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 120, y: 10)) + ) + + XCTAssertEqual(calledIDs, [secondID]) + } + func testMatchCallbackReceivesCompletedSnapshot() { let eventSource = GestureEventSourceDouble(startResult: true) let matchedID = UUID() @@ -95,9 +361,9 @@ final class GestureRecognizerMatchingTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 100, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) ) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 120, y: 10)) ), @@ -137,13 +403,13 @@ final class GestureRecognizerMatchingTests: XCTestCase { _ = eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) - XCTAssertEqual( + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 100, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) ), .consume ) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 120, y: 10)) ), @@ -153,6 +419,63 @@ final class GestureRecognizerMatchingTests: XCTestCase { XCTAssertTrue(calledIDs.isEmpty) } + func testDirectionChangeAtMinimumDistanceCompletesRegisteredPattern() { + let eventSource = GestureEventSourceDouble(startResult: true) + let matchedID = UUID() + var calledIDs: [UUID] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right, .down], match: matchedID) + return matcher + }, + onMatch: { calledIDs.append($0) }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 10, + minimumDirectionChangeAxisDistance: 25 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + recognizer.start() + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ), + .consume + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 10, y: 0)) + ), + .consume + ) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 10, y: 25)) + ), + .consume + ) + + XCTAssertTrue(recognizer.snapshot.trace.isVisible) + XCTAssertEqual(recognizer.snapshot.trace.directions, [.right, .down]) + + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 10, y: 25)) + ), + .consume + ) + XCTAssertEqual(calledIDs, [matchedID]) + } + func testShortSharedPrefixPatternMatchesWhenGestureEndsAtPrefix() { let eventSource = GestureEventSourceDouble(startResult: true) let shortID = UUID() @@ -185,7 +508,7 @@ final class GestureRecognizerMatchingTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 100, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) ) _ = eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 120, y: 10)) @@ -226,10 +549,10 @@ final class GestureRecognizerMatchingTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 100, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 100, y: 100)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 100, y: 100)) ) _ = eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 100, y: 120)) @@ -269,30 +592,40 @@ final class GestureRecognizerMatchingTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 100, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 100, y: -80)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 100, y: -80)) ) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) - XCTAssertEqual( + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + XCTAssertEqual(recognizer.snapshot.trace.directions, []) + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 100, y: -100)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 140, y: -100)) ), - .consume + .passThrough + ) + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + XCTAssertEqual(recognizer.snapshot.trace.directions, []) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 160, y: -100)) + ), + .passThrough ) XCTAssertTrue(calledIDs.isEmpty) - XCTAssertEqual( + assertReplayRequests( replayRequests, [ - .drag( + .dragStart( button: .secondary, points: [ .init(x: 10, y: 10), .init(x: 100, y: 10), .init(x: 100, y: -80), - .init(x: 100, y: -100), ] ) ] @@ -301,7 +634,7 @@ final class GestureRecognizerMatchingTests: XCTestCase { } @MainActor -private final class MatchingSnapshotProbe: @unchecked Sendable { +private final class MatchingSnapshotProbe { var recognizer: GestureRecognizer? var snapshot: GestureRecognizerState? diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerModifierPolicyTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerModifierPolicyTests.swift index 81af054..94ca30b 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerModifierPolicyTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerModifierPolicyTests.swift @@ -34,7 +34,7 @@ final class GestureRecognizerModifierPolicyTests: XCTestCase { let down = makeGestureInputEvent( kind: .buttonDown(.secondary), location: .init(x: 10, y: 10), modifiers: [.command]) let result = eventSource.send(down) - XCTAssertEqual(result, .consume) + assertDisposition(result, .consume) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertNil(recognizer.snapshot.status.lastFailure) } @@ -64,12 +64,12 @@ final class GestureRecognizerModifierPolicyTests: XCTestCase { let down = makeGestureInputEvent( kind: .buttonDown(.secondary), location: .init(x: 10, y: 10), modifiers: []) let result = eventSource.send(down) - XCTAssertEqual(result, .passThrough) + assertDisposition(result, .passThrough) XCTAssertEqual(recognizer.snapshot.status.lastFailure, .modifiersNotSatisfied) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) } - func testRepeatedButtonDownDuringActiveSessionDoesNotRecheckModifiers() { + func testRepeatedButtonDownDuringActiveSessionRechecksModifiersForNewInput() { let eventSource = GestureEventSourceDouble(startResult: true) var modifierPolicyCallCount = 0 var releaseRequests: [GesturePoint] = [] @@ -104,7 +104,7 @@ final class GestureRecognizerModifierPolicyTests: XCTestCase { recognizer.start() - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent( kind: .buttonDown(.secondary), @@ -113,10 +113,10 @@ final class GestureRecognizerModifierPolicyTests: XCTestCase { )), .consume ) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent( - kind: .buttonDragged(.secondary), + kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10), modifiers: [.command] )), @@ -124,26 +124,26 @@ final class GestureRecognizerModifierPolicyTests: XCTestCase { ) XCTAssertTrue(recognizer.snapshot.status.isCapturingGesture) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 120, y: 10)) ), - .consume + .passThrough ) - XCTAssertEqual(modifierPolicyCallCount, 1) - XCTAssertTrue(recognizer.snapshot.status.isCapturingGesture) - XCTAssertNil(recognizer.snapshot.status.lastFailure) + XCTAssertEqual(modifierPolicyCallCount, 2) + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertEqual(recognizer.snapshot.status.lastFailure, .modifiersNotSatisfied) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent( kind: .buttonUp(.secondary), location: .init(x: 140, y: 10), modifiers: [.command] )), - .consume + .passThrough ) - XCTAssertEqual(releaseRequests, [.init(x: 140, y: 10)]) + XCTAssertEqual(releaseRequests, [.init(x: 100, y: 10)]) } func testRecordingModeGestureStartClearsStaleModifierFailure() { @@ -163,7 +163,7 @@ final class GestureRecognizerModifierPolicyTests: XCTestCase { recognizer.start() - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ), @@ -172,7 +172,7 @@ final class GestureRecognizerModifierPolicyTests: XCTestCase { XCTAssertEqual(recognizer.snapshot.status.lastFailure, .modifiersNotSatisfied) recognizer.isRecordingModeEnabled = true - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 20, y: 20)) ), diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerObservationFilteringTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerObservationFilteringTests.swift deleted file mode 100644 index 7a3d3eb..0000000 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerObservationFilteringTests.swift +++ /dev/null @@ -1,112 +0,0 @@ -import XCTest - -import PointerGestureKit - -@MainActor -final class GestureRecognizerObservationFilteringTests: XCTestCase { - func testObserveStatusSkipsTraceOnlyUpdates() async { - let eventSource = GestureEventSourceDouble(startResult: true) - let recognizer = makeRecognizer(eventSource: eventSource) - - recognizer.isRecordingModeEnabled = true - recognizer.start() - _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 0, y: 0))) - _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 40, y: 0)) - ) - - var statuses: [GestureRecognizerState.Status] = [] - let token = recognizer.observeStatus { status in - statuses.append(status) - } - XCTAssertEqual(statuses.count, 1) - - _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 40, y: 80)) - ) - XCTAssertEqual(recognizer.snapshot.trace.directions.last, .down) - - await XCTAssertNotEventually(timeout: 0.05) { statuses.count > 1 } - XCTAssertEqual(statuses.count, 1) - - _ = token - } - - func testObserveStatusEmitsRecordingModeChanges() async { - let eventSource = GestureEventSourceDouble(startResult: true) - let recognizer = makeRecognizer(eventSource: eventSource) - - var statuses: [GestureRecognizerState.Status] = [] - let token = recognizer.observeStatus { status in - statuses.append(status) - } - XCTAssertEqual(statuses.map(\.isRecordingModeEnabled), [false]) - - recognizer.isRecordingModeEnabled = true - await XCTAssertEventually(timeout: 0.2) { statuses.count >= 2 } - - XCTAssertEqual(statuses.map(\.isRecordingModeEnabled), [false, true]) - - _ = token - } - - func testObserveTraceSkipsStatusOnlyUpdates() async { - let eventSource = GestureEventSourceDouble(startResult: true) - let recognizer = makeRecognizer(eventSource: eventSource) - - var traces: [GestureRecognizerState.Trace] = [] - let token = recognizer.observeTrace { trace in - traces.append(trace) - } - XCTAssertEqual(traces.count, 1) - - recognizer.start() - XCTAssertEqual(recognizer.snapshot.status.lifecycle, .ready) - - await XCTAssertNotEventually(timeout: 0.05) { traces.count > 1 } - XCTAssertEqual(traces.count, 1) - - _ = token - } - - func testObserveTraceEmitsTraceChanges() async { - let eventSource = GestureEventSourceDouble(startResult: true) - let recognizer = makeRecognizer(eventSource: eventSource) - - var traces: [GestureRecognizerState.Trace] = [] - let token = recognizer.observeTrace { trace in - traces.append(trace) - } - XCTAssertEqual(traces.map(\.directions), [[]]) - - recognizer.isRecordingModeEnabled = true - recognizer.start() - _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 0, y: 0))) - _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 40, y: 0)) - ) - - await XCTAssertEventually(timeout: 0.2) { traces.contains { $0.directions == [.right] } } - - XCTAssertEqual(traces.map(\.directions), [[], [.right]]) - - _ = token - } - - private func makeRecognizer(eventSource: GestureEventSourceDouble) -> GestureRecognizer { - let configuration: GestureRecognizerConfiguration = - makeGestureRecognizerTestConfiguration( - tuning: .testing( - minimumGestureStartAxisDistance: 0, - minimumDirectionChangeAxisDistance: 0 - ) - ) - - return GestureRecognizer( - eventSource: eventSource, - configuration: configuration - ) - } -} diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerRecordingModeTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerRecordingModeTests.swift index d0397a6..df7d8b5 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerRecordingModeTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerRecordingModeTests.swift @@ -30,7 +30,7 @@ final class GestureRecognizerRecordingModeTests: XCTestCase { let down = makeGestureInputEvent( kind: .buttonDown(.secondary), location: .init(x: 10, y: 10), modifiers: []) let result = eventSource.send(down) - XCTAssertEqual(result, .consume) + assertDisposition(result, .consume) XCTAssertNil(recognizer.snapshot.status.lastFailure) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) } @@ -49,6 +49,8 @@ final class GestureRecognizerRecordingModeTests: XCTestCase { clickRequestCount += 1 case let .drag(_, points): sequencePoints = points + case let .dragStart(_, points): + XCTFail("Unexpected partial drag replay with \(points.count) points.") case let .release(_, point): releaseRequestCount += 1 XCTFail("Unexpected release-only replay at \(point).") @@ -73,7 +75,7 @@ final class GestureRecognizerRecordingModeTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 40, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10)) ) _ = eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 60, y: 10)) @@ -88,11 +90,98 @@ final class GestureRecognizerRecordingModeTests: XCTestCase { XCTAssertEqual(recognizer.snapshot.status.lastRecordedDirections, [.right]) } - func testRecordingModeRecordsDirectionsWithoutRegisteredMatcher() { + func testLastRecordedDirectionsRemainUntilNextGestureCompletes() { let eventSource = GestureEventSourceDouble(startResult: true) let configuration: GestureRecognizerConfiguration = makeGestureRecognizerTestConfiguration( + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + recognizer.isRecordingModeEnabled = true + + recognizer.start() + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 0, y: 0)) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 0)) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 40, y: 0)) + ) + XCTAssertEqual(recognizer.snapshot.status.lastRecordedDirections, [.right]) + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 0, y: 0)) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 0, y: 40)) + ) + + XCTAssertEqual(recognizer.snapshot.status.lastRecordedDirections, [.right]) + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 0, y: 40)) + ) + + XCTAssertEqual(recognizer.snapshot.status.lastRecordedDirections, [.down]) + } + + func testRecordingModeRecordsAllCardinalDirections() { + let cases: [(end: GesturePoint, direction: GestureDirection)] = [ + (.init(x: 30, y: 0), .right), + (.init(x: -30, y: 0), .left), + (.init(x: 0, y: 30), .down), + (.init(x: 0, y: -30), .up), + ] + + for testCase in cases { + let eventSource = GestureEventSourceDouble(startResult: true) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: makeGestureRecognizerTestConfiguration( + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + ) + recognizer.isRecordingModeEnabled = true + recognizer.start() + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: testCase.end) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: testCase.end) + ) + + XCTAssertEqual(recognizer.snapshot.status.lastRecordedDirections, [testCase.direction]) + } + } + + func testRecordingModeRecordsDirectionsWithoutRequestingMatcher() { + let eventSource = GestureEventSourceDouble(startResult: true) + var makeMatcherCallCount = 0 + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + makeMatcherCallCount += 1 + return GesturePatternMatcher() + }, tuning: .testing( minimumGestureStartAxisDistance: 0, minimumDirectionChangeAxisDistance: 100 @@ -111,13 +200,65 @@ final class GestureRecognizerRecordingModeTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 20, y: 0)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 20, y: 0)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 30, y: 0)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 30, y: 0)) ) XCTAssertEqual(recognizer.snapshot.trace.directions, [.right]) - XCTAssertEqual(recognizer.snapshot.trace.directionEndpoints.last, .init(x: 30, y: 0)) + XCTAssertEqual(recognizer.snapshot.trace.directionEndpoints, [.zero, .init(x: 30, y: 0)]) + XCTAssertEqual(makeMatcherCallCount, 0) + } + + func testRecordingModeSessionContinuesRecordingWhenModeIsDisabledMidGesture() { + let eventSource = GestureEventSourceDouble(startResult: true) + var makeMatcherCallCount = 0 + var replayRequests: [GestureReplayRequest] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + makeMatcherCallCount += 1 + return GesturePatternMatcher() + }, + onReplayRequested: { replayRequests.append($0) }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + recognizer.isRecordingModeEnabled = true + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + + recognizer.isRecordingModeEnabled = false + + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 20, y: 0)) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 40, y: 0)) + ) + + XCTAssertEqual(makeMatcherCallCount, 0) + XCTAssertEqual(recognizer.snapshot.status.lastRecordedDirections, [.right]) + assertReplayRequests( + replayRequests, + [ + .drag( + button: .secondary, + points: [.zero, .init(x: 20, y: 0), .init(x: 40, y: 0)] + ) + ] + ) } } diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerReplayTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerReplayTests.swift index 437822b..253b565 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerReplayTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerReplayTests.swift @@ -4,7 +4,72 @@ import PointerGestureKit @MainActor final class GestureRecognizerReplayTests: XCTestCase { - func testUnmatchedGestureRequestsButtonDragReplay() { + func testStoppedUnmatchedGestureReplaysConsumedDragStartAndReleasesSession() { + let eventSource = GestureEventSourceDouble(startResult: true) + var replayRequests: [GestureReplayRequest] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.left], match: UUID()) + return matcher + }, + onReplayRequested: { replayRequests.append($0) }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) + ) + + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10)) + ), + .consume + ) + + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + assertReplayRequests( + replayRequests, + [ + .dragStart( + button: .secondary, + points: [.init(x: 10, y: 10), .init(x: 40, y: 10)] + ) + ] + ) + + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 45, y: 10)) + ), + .passThrough + ) + assertReplayRequests( + replayRequests, + [ + .dragStart( + button: .secondary, + points: [.init(x: 10, y: 10), .init(x: 40, y: 10)] + ) + ] + ) + } + + func testUnmatchedGestureRequestsDragStartReplay() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -29,24 +94,80 @@ final class GestureRecognizerReplayTests: XCTestCase { _ = eventSource.send(down) let drag = makeGestureInputEvent( - kind: .buttonDragged(.secondary), location: .init(x: 100, y: 10)) - XCTAssertEqual(eventSource.send(drag), .consume) + kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) + assertDisposition(eventSource.send(drag), .consume) + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) let up = makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 120, y: 10)) - XCTAssertEqual(eventSource.send(up), .consume) + assertDisposition(eventSource.send(up), .passThrough) - XCTAssertEqual( + assertReplayRequests( replayRequests, [ - .drag( + .dragStart( button: .secondary, - points: [.init(x: 10, y: 10), .init(x: 100, y: 10), .init(x: 120, y: 10)] + points: [.init(x: 10, y: 10), .init(x: 100, y: 10)] ) ] ) } - func testUnmatchedPrimaryButtonGestureRequestsPrimaryButtonDragReplay() { + func testUnmatchedGestureReleasesSessionAfterDragStartReplay() { + let eventSource = GestureEventSourceDouble(startResult: true) + var replayRequests: [GestureReplayRequest] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + onReplayRequested: { replayRequests.append($0) }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0, + maximumRawPointCount: 3 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 0, y: 0)) + ) + + for point in [ + GesturePoint(x: 20, y: 0), + GesturePoint(x: 40, y: 0), + GesturePoint(x: 60, y: 0), + GesturePoint(x: 80, y: 0), + ] { + assertDisposition( + eventSource.send(makeGestureInputEvent(kind: .buttonMoved(.secondary), location: point)), + point == GesturePoint(x: 20, y: 0) ? .consume : .passThrough + ) + } + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 100, y: 0)) + ), + .passThrough + ) + + assertReplayRequests( + replayRequests, + [ + .dragStart( + button: .secondary, + points: [.init(x: 0, y: 0), .init(x: 20, y: 0)] + ) + ] + ) + } + + func testUnmatchedPrimaryButtonGestureRequestsPrimaryButtonDragStartReplay() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -71,25 +192,25 @@ final class GestureRecognizerReplayTests: XCTestCase { _ = eventSource.send( makeGestureInputEvent(kind: .buttonDown(.primary), location: .init(x: 10, y: 10)) ) - XCTAssertEqual( + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.primary), location: .init(x: 100, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.primary), location: .init(x: 100, y: 10)) ), .consume ) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.primary), location: .init(x: 120, y: 10)) ), - .consume + .passThrough ) - XCTAssertEqual( + assertReplayRequests( replayRequests, [ - .drag( + .dragStart( button: .primary, - points: [.init(x: 10, y: 10), .init(x: 100, y: 10), .init(x: 120, y: 10)] + points: [.init(x: 10, y: 10), .init(x: 100, y: 10)] ) ] ) @@ -125,13 +246,13 @@ final class GestureRecognizerReplayTests: XCTestCase { _ = eventSource.send(down) let drag = makeGestureInputEvent( - kind: .buttonDragged(.secondary), location: .init(x: 100, y: 10)) - XCTAssertEqual(eventSource.send(drag), .consume) + kind: .buttonMoved(.secondary), location: .init(x: 100, y: 10)) + assertDisposition(eventSource.send(drag), .consume) let up = makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 120, y: 10)) - XCTAssertEqual(eventSource.send(up), .consume) + assertDisposition(eventSource.send(up), .consume) - XCTAssertEqual( + assertReplayRequests( replayRequests, [ .drag( @@ -166,26 +287,80 @@ final class GestureRecognizerReplayTests: XCTestCase { _ = eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) ) - XCTAssertEqual( + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 40, y: 40))), + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 40))), .consume ) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertEqual(recognizer.snapshot.trace.directions, []) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 40, y: 40))), .consume ) - XCTAssertEqual( + assertReplayRequests( replayRequests, [.drag(button: .secondary, points: [.zero, .init(x: 40, y: 40)])] ) } + func testPendingButtonDragReplayRetainsStartAndRecentPointsWithinRawPointLimit() { + let eventSource = GestureEventSourceDouble(startResult: true) + var replayRequests: [GestureReplayRequest] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + onReplayRequested: { replayRequests.append($0) }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 50, + minimumDirectionChangeAxisDistance: 0, + maximumRawPointCount: 3 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) + ) + + for point in [ + GesturePoint(x: 20, y: 20), + GesturePoint(x: 40, y: 40), + GesturePoint(x: 60, y: 60), + GesturePoint(x: 80, y: 80), + ] { + assertDisposition( + eventSource.send(makeGestureInputEvent(kind: .buttonMoved(.secondary), location: point)), + .consume + ) + } + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 90, y: 90)) + ), + .consume + ) + + assertReplayRequests( + replayRequests, + [ + .drag( + button: .secondary, + points: [.zero, .init(x: 80, y: 80), .init(x: 90, y: 90)] + ) + ] + ) + } + func testDominantDragAfterEqualAxisDragRequestsButtonReleaseReplay() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -216,29 +391,29 @@ final class GestureRecognizerReplayTests: XCTestCase { _ = eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero) ) - XCTAssertEqual( + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 40, y: 40))), + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 40))), .consume ) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) - XCTAssertEqual( + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 80, y: 40))), + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 80, y: 40))), .consume ) XCTAssertEqual(recognizer.snapshot.trace.directions, [.right]) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 100, y: 40))), .consume ) - XCTAssertEqual(replayRequests, [.release(button: .secondary, at: .init(x: 100, y: 40))]) + assertReplayRequests(replayRequests, [.release(button: .secondary, at: .init(x: 100, y: 40))]) } - func testInvalidGestureUpdatesClickReplayPointBeforeReplay() { + func testInvalidGestureRequestsDragStartReplayBeforeFurtherInput() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -266,8 +441,8 @@ final class GestureRecognizerReplayTests: XCTestCase { for event in [ makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)), - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 10, y: 60)), - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 10, y: 80)), + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 10, y: 60)), + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 10, y: 80)), ] { _ = eventSource.send(event) } @@ -277,15 +452,14 @@ final class GestureRecognizerReplayTests: XCTestCase { location: .init(x: 10, y: 100) )) - XCTAssertEqual(result, .consume) - XCTAssertEqual( + assertDisposition(result, .passThrough) + assertReplayRequests( replayRequests, [ - .drag( + .dragStart( button: .secondary, points: [ - .init(x: 10, y: 10), .init(x: 10, y: 60), .init(x: 10, y: 80), - .init(x: 10, y: 100), + .init(x: 10, y: 10), .init(x: 10, y: 60), ] ) ] diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerSessionExpirationTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerSessionExpirationTests.swift index c47294a..1a58e1a 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerSessionExpirationTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerSessionExpirationTests.swift @@ -5,9 +5,8 @@ import PointerGestureKit @MainActor final class GestureRecognizerSessionExpirationTests: XCTestCase { - private static let shortExpirationSleepNanoseconds: UInt64 = 30_000_000 + private static let shortExpirationTimeout: TimeInterval = 0.03 private static let completedGestureExpirationDuration: TimeInterval = 0.03 - private static let completedGestureExpirationSleepNanoseconds: UInt64 = 60_000_000 func testActiveSessionExpirationRequestsReleaseAndClearsTrace() async { let eventSource = GestureEventSourceDouble(startResult: true) @@ -26,7 +25,7 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 30, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 30, y: 10)) ) await XCTAssertEventually(timeout: 1) { @@ -35,13 +34,13 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { } XCTAssertEqual(recognizer.snapshot.status.lastFailure, .gestureSessionExpired) - XCTAssertEqual(replayRequests, [.release(button: .secondary, at: .init(x: 30, y: 10))]) + assertReplayRequests(replayRequests, [.release(button: .secondary, at: .init(x: 30, y: 10))]) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertFalse(recognizer.snapshot.trace.isVisible) XCTAssertTrue(recognizer.snapshot.trace.rawPoints.isEmpty) } - func testStoppedSessionExpirationReplaysConsumedButtonInput() async { + func testUnmatchedStoppedSessionReplaysDragStartWithoutWaitingForExpiration() async { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -57,24 +56,26 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 30, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 30, y: 10)) ) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + XCTAssertTrue(recognizer.snapshot.trace.directions.isEmpty) - await XCTAssertEventually(timeout: 1) { - recognizer.snapshot.status.lastFailure == .gestureSessionExpired + await XCTAssertNotEventually(timeout: Self.shortExpirationTimeout) { + recognizer.snapshot.status.lastFailure != nil } - XCTAssertEqual(recognizer.snapshot.status.lastFailure, .gestureSessionExpired) - XCTAssertEqual( + XCTAssertNil(recognizer.snapshot.status.lastFailure) + assertReplayRequests( replayRequests, - [.drag(button: .secondary, points: [.init(x: 10, y: 10), .init(x: 30, y: 10)])] + [.dragStart(button: .secondary, points: [.init(x: 10, y: 10), .init(x: 30, y: 10)])] ) XCTAssertFalse(recognizer.snapshot.trace.isVisible) XCTAssertTrue(recognizer.snapshot.trace.rawPoints.isEmpty) } - func testPendingButtonInputDoesNotScheduleSessionExpiration() async throws { + func testPendingButtonInputExpirationReplaysClickAndClearsInput() async { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -86,26 +87,76 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { ) recognizer.start() - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ), .consume ) - try await Task.sleep(nanoseconds: Self.shortExpirationSleepNanoseconds) + await XCTAssertEventually(timeout: 1) { + recognizer.snapshot.status.lastFailure == .gestureSessionExpired + && replayRequests == [.click(button: .secondary, at: .init(x: 10, y: 10))] + } - XCTAssertNil(recognizer.snapshot.status.lastFailure) + XCTAssertEqual(recognizer.snapshot.status.lastFailure, .gestureSessionExpired) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) - XCTAssertTrue(replayRequests.isEmpty) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 10, y: 10)) ), + .passThrough + ) + assertReplayRequests(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) + } + + func testMovedPendingButtonInputBelowStartDistanceExpirationReplaysClickAndClearsInput() async { + let eventSource = GestureEventSourceDouble(startResult: true) + var replayRequests: [GestureReplayRequest] = [] + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: makeSessionExpirationConfiguration( + minimumGestureStartAxisDistance: 20, + onReplayRequested: { replayRequests.append($0) } + ) + ) + + recognizer.start() + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) + ), .consume ) - XCTAssertEqual(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 19, y: 10)) + ), + .consume + ) + + await XCTAssertEventually(timeout: 1) { + recognizer.snapshot.status.lastFailure == .gestureSessionExpired + && replayRequests == [.click(button: .secondary, at: .init(x: 10, y: 10))] + } + + XCTAssertEqual(recognizer.snapshot.status.lastFailure, .gestureSessionExpired) + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + + assertDisposition( + eventSource.send( + makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 20, y: 10)) + ), + .passThrough + ) + assertReplayRequests( + replayRequests, + [.click(button: .secondary, at: .init(x: 10, y: 10))] + ) } func testSuccessfulGestureStartClearsStaleSessionExpirationFailure() async { @@ -113,7 +164,9 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { let recognizer = GestureRecognizer( eventSource: eventSource, - configuration: makeSessionExpirationConfiguration() + configuration: makeSessionExpirationConfiguration( + makeMatcher: { _ in Self.makeRightGestureMatcher() } + ) ) recognizer.start() @@ -121,14 +174,14 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 30, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 30, y: 10)) ) await XCTAssertEventually(timeout: 1) { recognizer.snapshot.status.lastFailure == .gestureSessionExpired } - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 40, y: 10)) ), @@ -137,7 +190,7 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { XCTAssertNil(recognizer.snapshot.status.lastFailure) } - func testNilMaximumGestureSessionDurationDisablesSessionExpiration() async throws { + func testNilMaximumGestureSessionDurationDisablesSessionExpiration() async { let eventSource = GestureEventSourceDouble(startResult: true) let recognizer = GestureRecognizer( @@ -153,22 +206,26 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 30, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 30, y: 10)) ) - try await Task.sleep(nanoseconds: Self.shortExpirationSleepNanoseconds) + await XCTAssertNotEventually(timeout: Self.shortExpirationTimeout) { + recognizer.snapshot.status.lastFailure != nil + || !recognizer.snapshot.status.isCapturingGesture + } XCTAssertNil(recognizer.snapshot.status.lastFailure) XCTAssertTrue(recognizer.snapshot.status.isCapturingGesture) } - func testCompletedGestureCancelsPendingSessionExpiration() async throws { + func testCompletedGestureCancelsPendingSessionExpiration() async { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] let recognizer = GestureRecognizer( eventSource: eventSource, configuration: makeSessionExpirationConfiguration( + makeMatcher: { _ in Self.makeRightGestureMatcher() }, maximumGestureSessionDuration: Self.completedGestureExpirationDuration, onReplayRequested: { replayRequests.append($0) } ) @@ -179,26 +236,28 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 30, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 30, y: 10)) ) - XCTAssertEqual( + assertDisposition( eventSource.send( makeGestureInputEvent(kind: .buttonUp(.secondary), location: .init(x: 30, y: 10)) ), .consume ) - try await Task.sleep(nanoseconds: Self.completedGestureExpirationSleepNanoseconds) + await XCTAssertNotEventually(timeout: Self.completedGestureExpirationTimeout) { + recognizer.snapshot.status.lastFailure != nil + } XCTAssertNil(recognizer.snapshot.status.lastFailure) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) - XCTAssertEqual( + assertReplayRequests( replayRequests, - [.drag(button: .secondary, points: [.init(x: 10, y: 10), .init(x: 30, y: 10)])] + [.release(button: .secondary, at: .init(x: 30, y: 10))] ) } - func testStoppingRecognizerCancelsPendingSessionExpiration() async throws { + func testStoppingRecognizerCancelsPendingSessionExpiration() async { let eventSource = GestureEventSourceDouble(startResult: true) let recognizer = GestureRecognizer( @@ -211,11 +270,14 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 30, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 30, y: 10)) ) recognizer.stop() - try await Task.sleep(nanoseconds: Self.shortExpirationSleepNanoseconds) + await XCTAssertNotEventually(timeout: Self.shortExpirationTimeout) { + recognizer.snapshot.status.lastFailure != nil + || recognizer.snapshot.status.isCapturingGesture + } XCTAssertNil(recognizer.snapshot.status.lastFailure) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) @@ -226,6 +288,7 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { @escaping @MainActor @Sendable (GestureRecognitionContext?) -> GesturePatternMatcher = { _ in GesturePatternMatcher() }, maximumGestureSessionDuration: TimeInterval? = 0.01, + minimumGestureStartAxisDistance: Double = 0, onReplayRequested: @escaping @MainActor @Sendable (GestureReplayRequest) -> Void = { _ in } ) -> GestureRecognizerConfiguration { makeGestureRecognizerTestConfiguration( @@ -233,7 +296,7 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { onReplayRequested: onReplayRequested, areModifiersSatisfied: { _, _ in true }, tuning: .testing( - minimumGestureStartAxisDistance: 0, + minimumGestureStartAxisDistance: minimumGestureStartAxisDistance, minimumDirectionChangeAxisDistance: 0, maximumGestureSessionDuration: maximumGestureSessionDuration ) @@ -245,4 +308,8 @@ final class GestureRecognizerSessionExpirationTests: XCTestCase { matcher.register(pattern: [.right], match: UUID()) return matcher } + + private static var completedGestureExpirationTimeout: TimeInterval { + completedGestureExpirationDuration * 2 + } } diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStartStopTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStartStopTests.swift index 43f5e42..f368e49 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStartStopTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStartStopTests.swift @@ -38,7 +38,7 @@ final class GestureRecognizerStartStopTests: XCTestCase { recognizer.stop() XCTAssertEqual(recognizer.snapshot.status.lifecycle, .idle) XCTAssertEqual(eventSource.stopCount, 1) - XCTAssertEqual( + assertDisposition( eventSource.send(makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero)), .passThrough ) @@ -90,12 +90,12 @@ final class GestureRecognizerStartStopTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 40, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10)) ) recognizer.stop() - XCTAssertEqual(replayRequests, [.release(button: .secondary, at: .init(x: 40, y: 10))]) + assertReplayRequests(replayRequests, [.release(button: .secondary, at: .init(x: 40, y: 10))]) XCTAssertEqual(stopCountWhenReplayWasRequested, 0) XCTAssertEqual(recognizer.snapshot.status.lifecycle, .idle) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) @@ -124,13 +124,13 @@ final class GestureRecognizerStartStopTests: XCTestCase { recognizer.stop() - XCTAssertEqual(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) + assertReplayRequests(replayRequests, [.click(button: .secondary, at: .init(x: 10, y: 10))]) XCTAssertEqual(recognizer.snapshot.status.lifecycle, .idle) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertEqual(eventSource.stopCount, 1) } - func testStopReplaysMovedPendingButtonInput() { + func testStopReplaysMovedPendingButtonInputBelowStartDistanceAsClick() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -154,21 +154,21 @@ final class GestureRecognizerStartStopTests: XCTestCase { makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) _ = eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 20, y: 10)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 19, y: 10)) ) recognizer.stop() - XCTAssertEqual( + assertReplayRequests( replayRequests, - [.drag(button: .secondary, points: [.init(x: 10, y: 10), .init(x: 20, y: 10)])] + [.click(button: .secondary, at: .init(x: 10, y: 10))] ) XCTAssertEqual(recognizer.snapshot.status.lifecycle, .idle) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) XCTAssertEqual(eventSource.stopCount, 1) } - func testStopReplaysStoppedConsumedButtonDrag() { + func testStopReplaysUnmatchedActiveSessionAsConsumedButtonDrag() { let eventSource = GestureEventSourceDouble(startResult: true) var replayRequests: [GestureReplayRequest] = [] @@ -196,25 +196,71 @@ final class GestureRecognizerStartStopTests: XCTestCase { _ = eventSource.send( makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) ) - XCTAssertEqual( + assertDisposition( eventSource.send( - makeGestureInputEvent(kind: .buttonDragged(.secondary), location: .init(x: 10, y: 60)) + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 10, y: 60)) ), .consume ) XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + XCTAssertTrue(recognizer.snapshot.trace.directions.isEmpty) recognizer.stop() - XCTAssertEqual( + assertReplayRequests( replayRequests, - [.drag(button: .secondary, points: [.init(x: 10, y: 10), .init(x: 10, y: 60)])] + [.dragStart(button: .secondary, points: [.init(x: 10, y: 10), .init(x: 10, y: 60)])] ) XCTAssertEqual(recognizer.snapshot.status.lifecycle, .idle) XCTAssertFalse(recognizer.snapshot.trace.isVisible) XCTAssertEqual(eventSource.stopCount, 1) } + func testStopHidesImmediatelyAfterVisibleExactMatchTrace() async { + let eventSource = GestureEventSourceDouble(startResult: true) + var traceVisibility: [Bool] = [] + + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: UUID()) + return matcher + }, + areModifiersSatisfied: { _, _ in true }, + tuning: .testing( + minimumGestureStartAxisDistance: 0, + minimumDirectionChangeAxisDistance: 0 + ) + ) + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + let observation = recognizer.observeTrace { trace in + traceVisibility.append(trace.isVisible) + } + defer { observation.cancel() } + + recognizer.start() + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonDown(.secondary), location: .init(x: 10, y: 10)) + ) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 40, y: 10)) + ) + + recognizer.stop() + try? await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertEqual(traceVisibility, [false, true, false]) + XCTAssertEqual(recognizer.snapshot.status.lifecycle, .idle) + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + XCTAssertFalse(recognizer.snapshot.trace.isVisible) + } + func testStopClearsLastFailure() { let eventSource = GestureEventSourceDouble(startResult: true) diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStartupRetryTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStartupRetryTests.swift index 9517638..9eebf3f 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStartupRetryTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStartupRetryTests.swift @@ -43,13 +43,13 @@ final class GestureRecognizerStartupRetryTests: XCTestCase { recognizer.start() - XCTAssertEqual( + assertDisposition( eventSource.send(makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero)), .passThrough ) } - func testFailedStartWithoutRetryDelaysDoesNotRetryLater() async throws { + func testFailedStartWithoutRetryDelaysDoesNotRetryLater() async { let eventSource = GestureEventSourceDouble(startResults: [false, true]) let configuration: GestureRecognizerConfiguration = @@ -63,7 +63,10 @@ final class GestureRecognizerStartupRetryTests: XCTestCase { ) recognizer.start() - try await Task.sleep(nanoseconds: 50_000_000) + + await XCTAssertNotEventually(timeout: 0.05) { + eventSource.startCount > 1 + } XCTAssertEqual(recognizer.snapshot.status.lifecycle, .failed) XCTAssertEqual( @@ -129,7 +132,7 @@ final class GestureRecognizerStartupRetryTests: XCTestCase { } func testCallingStartAgainCancelsPendingRetryAndRetriesImmediatelyAfterStartFailure() - async throws + async { let eventSource = GestureEventSourceDouble(startResults: [false, true]) @@ -150,7 +153,9 @@ final class GestureRecognizerStartupRetryTests: XCTestCase { XCTAssertNil(recognizer.snapshot.status.lastFailure) XCTAssertEqual(eventSource.startCount, 2) - try await Task.sleep(nanoseconds: 50_000_000) + await XCTAssertNotEventually(timeout: 0.05) { + eventSource.startCount > 2 + } XCTAssertEqual(recognizer.snapshot.status.lifecycle, .ready) XCTAssertEqual(eventSource.startCount, 2) } @@ -176,7 +181,7 @@ final class GestureRecognizerStartupRetryTests: XCTestCase { XCTAssertEqual(eventSource.startCount, 2) } - func testStopCancelsPendingRetry() async throws { + func testStopCancelsPendingRetry() async { let eventSource = GestureEventSourceDouble(startResults: [false, true]) let configuration: GestureRecognizerConfiguration = @@ -197,40 +202,43 @@ final class GestureRecognizerStartupRetryTests: XCTestCase { XCTAssertNil(recognizer.snapshot.status.lastFailure) XCTAssertEqual(eventSource.stopCount, 1) - try await Task.sleep(nanoseconds: 50_000_000) + await XCTAssertNotEventually(timeout: 0.05) { + eventSource.startCount > 1 + } XCTAssertEqual(eventSource.startCount, 1) } - func testHugeFiniteEventSourceStartRetryDelayCanBeScheduled() async { - let eventSource = GestureEventSourceDouble(startResults: [false, true]) - - let configuration: GestureRecognizerConfiguration = - makeGestureRecognizerTestConfiguration( - tuning: .testing(eventSourceStartRetryDelays: [.greatestFiniteMagnitude]) - ) - - let recognizer = GestureRecognizer( + func testReleaseWhileStartupRetryIsPendingDoesNotRetainRecognizer() async { + let eventSource = GestureEventSourceDouble(startResult: false) + weak var weakRecognizer: GestureRecognizer? + var recognizer: GestureRecognizer? = GestureRecognizer( eventSource: eventSource, - configuration: configuration + configuration: makeGestureRecognizerTestConfiguration( + tuning: .testing(eventSourceStartRetryDelays: [0.01]) + ) ) + weakRecognizer = recognizer - recognizer.start() - await Task.yield() + recognizer?.start() + await XCTAssertEventually(timeout: 1) { + eventSource.startCount >= 2 + } - XCTAssertEqual(recognizer.snapshot.status.lifecycle, .retrying) - XCTAssertEqual( - recognizer.snapshot.status.lastFailure, - GestureRecognizerFailure.eventSourceStartFailed - ) - XCTAssertEqual(eventSource.startCount, 1) + recognizer = nil + + await XCTAssertEventually(timeout: 1) { + weakRecognizer == nil && eventSource.stopCount == 1 + } + XCTAssertNil(weakRecognizer) + XCTAssertEqual(eventSource.stopCount, 1) } - func testInvalidOnlyEventSourceStartRetryDelaysDoNotScheduleRetry() async throws { + func testHugeFiniteEventSourceStartRetryDelayCanBeScheduled() async { let eventSource = GestureEventSourceDouble(startResults: [false, true]) let configuration: GestureRecognizerConfiguration = makeGestureRecognizerTestConfiguration( - tuning: .testing(eventSourceStartRetryDelays: [-1, .nan, .infinity]) + tuning: .testing(eventSourceStartRetryDelays: [.greatestFiniteMagnitude]) ) let recognizer = GestureRecognizer( @@ -239,9 +247,9 @@ final class GestureRecognizerStartupRetryTests: XCTestCase { ) recognizer.start() - try await Task.sleep(nanoseconds: 50_000_000) + await Task.yield() - XCTAssertEqual(recognizer.snapshot.status.lifecycle, .failed) + XCTAssertEqual(recognizer.snapshot.status.lifecycle, .retrying) XCTAssertEqual( recognizer.snapshot.status.lastFailure, GestureRecognizerFailure.eventSourceStartFailed diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStatePublicAPITests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStatePublicAPITests.swift deleted file mode 100644 index 20e4842..0000000 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStatePublicAPITests.swift +++ /dev/null @@ -1,183 +0,0 @@ -import XCTest - -import PointerGestureKit - -final class GestureRecognizerStatePublicAPITests: XCTestCase { - @MainActor - func testSnapshotExposesObservableStateToClients() { - let eventSource = GestureEventSourceDouble(startResult: true) - let configuration: GestureRecognizerConfiguration = - makeGestureRecognizerTestConfiguration() - - let recognizer = GestureRecognizer( - eventSource: eventSource, - configuration: configuration - ) - - recognizer.isRecordingModeEnabled = true - recognizer.start() - - let state = recognizer.snapshot - XCTAssertEqual(state.status.lifecycle, .ready) - XCTAssertFalse(state.status.isCapturingGesture) - XCTAssertTrue(state.status.isRecordingModeEnabled) - XCTAssertTrue(state.status.lastRecordedDirections.isEmpty) - XCTAssertNil(state.status.lastFailure) - XCTAssertFalse(state.trace.isVisible) - XCTAssertTrue(state.trace.rawPoints.isEmpty) - XCTAssertTrue(state.trace.directions.isEmpty) - XCTAssertTrue(state.trace.directionEndpoints.isEmpty) - } - - func testRecognizerStateLifecycleAndFailureAreHashableValues() { - let status = GestureRecognizerState.Status( - lifecycle: .ready, - isCapturingGesture: true, - isRecordingModeEnabled: true, - lastRecordedDirections: [.right], - lastFailure: .modifiersNotSatisfied - ) - let trace = GestureRecognizerState.Trace( - isVisible: true, - rawPoints: [.init(x: 0, y: 0), .init(x: 12, y: 0)], - directions: [.right], - directionEndpoints: [.init(x: 0, y: 0), .init(x: 12, y: 0)] - ) - let state = GestureRecognizerState(status: status, trace: trace) - - XCTAssertEqual( - Set([ - GestureRecognizerState.Status.Lifecycle.idle, - .starting, - .ready, - .retrying, - .failed, - .ready, - ]).count, - 5 - ) - XCTAssertEqual( - Set([ - GestureRecognizerFailure.eventSourceStartFailed, - .recognitionDisabled, - .modifiersNotSatisfied, - .gestureSessionExpired, - .gestureSessionExpired, - ]).count, - 4 - ) - XCTAssertEqual(Set([status, status]).count, 1) - XCTAssertEqual(Set([trace, trace]).count, 1) - XCTAssertEqual(Set([state, state]).count, 1) - } - - func testRecognizerStateAndFailureAreSendableValues() { - assertSendable(GestureRecognizerState()) - assertSendable(GestureRecognizerState.Status()) - assertSendable(GestureRecognizerState.Trace()) - assertSendable(GestureRecognizerState.Status.Lifecycle.ready) - assertSendable(GestureRecognizerFailure.modifiersNotSatisfied) - } - - func testRecognizerStateCanBeConstructedByClients() { - let status = GestureRecognizerState.Status( - lifecycle: .idle, - isCapturingGesture: false, - isRecordingModeEnabled: true, - lastRecordedDirections: [.right], - lastFailure: .gestureSessionExpired - ) - let trace = GestureRecognizerState.Trace( - isVisible: true, - rawPoints: [.init(x: 0, y: 0), .init(x: 12, y: 0)], - directions: [.right], - directionEndpoints: [.init(x: 0, y: 0), .init(x: 12, y: 0)] - ) - let state = GestureRecognizerState(status: status, trace: trace) - - XCTAssertEqual(state.status.lifecycle, .idle) - XCTAssertFalse(state.status.isCapturingGesture) - XCTAssertTrue(state.status.isRecordingModeEnabled) - XCTAssertEqual(state.status.lastRecordedDirections, [.right]) - XCTAssertEqual(state.status.lastFailure, .gestureSessionExpired) - XCTAssertTrue(state.trace.isVisible) - XCTAssertEqual(state.trace.rawPoints, [.init(x: 0, y: 0), .init(x: 12, y: 0)]) - XCTAssertEqual(state.trace.directions, [.right]) - XCTAssertEqual(state.trace.directionEndpoints, [.init(x: 0, y: 0), .init(x: 12, y: 0)]) - } - - func testRecognizerTraceNormalizesDirectionEndpointsForClientConstructedState() { - let trace = GestureRecognizerState.Trace( - isVisible: true, - rawPoints: [.init(x: 0, y: 0), .init(x: 12, y: 0), .init(x: 12, y: 12)], - directions: [.right, .down], - directionEndpoints: [.init(x: 0, y: 0)] - ) - - XCTAssertEqual( - trace.directionEndpoints, - [.init(x: 0, y: 0), .init(x: 12, y: 0), .init(x: 12, y: 12)] - ) - } - - func testRecognizerTraceTrimsExcessDirectionEndpointsForClientConstructedState() { - let trace = GestureRecognizerState.Trace( - isVisible: true, - rawPoints: [ - .init(x: 0, y: 0), - .init(x: 12, y: 0), - .init(x: 24, y: 0), - .init(x: 24, y: 12), - ], - directions: [.right], - directionEndpoints: [ - .init(x: 0, y: 0), - .init(x: 12, y: 0), - .init(x: 24, y: 0), - .init(x: 24, y: 12), - ] - ) - - XCTAssertEqual( - trace.directionEndpoints, - [.init(x: 0, y: 0), .init(x: 24, y: 12)] - ) - } - - func testRecognizerStateProvidesEmptyDefaults() { - let state = GestureRecognizerState() - - XCTAssertEqual(state.status.lifecycle, .idle) - XCTAssertFalse(state.status.isCapturingGesture) - XCTAssertFalse(state.status.isRecordingModeEnabled) - XCTAssertTrue(state.status.lastRecordedDirections.isEmpty) - XCTAssertNil(state.status.lastFailure) - XCTAssertFalse(state.trace.isVisible) - XCTAssertTrue(state.trace.rawPoints.isEmpty) - XCTAssertTrue(state.trace.directions.isEmpty) - XCTAssertTrue(state.trace.directionEndpoints.isEmpty) - } - - func testRecognizerStateAndFailureDoNotExposeSerializationOrEnumerationContracts() { - XCTAssertFalse(GestureRecognizerState.self is any Codable.Type) - XCTAssertFalse(GestureRecognizerState.self is any RawRepresentable.Type) - XCTAssertFalse(GestureRecognizerState.self is any CaseIterable.Type) - XCTAssertFalse(GestureRecognizerState.self is any Error.Type) - XCTAssertFalse(GestureRecognizerState.Status.self is any Codable.Type) - XCTAssertFalse(GestureRecognizerState.Status.self is any RawRepresentable.Type) - XCTAssertFalse(GestureRecognizerState.Status.self is any CaseIterable.Type) - XCTAssertFalse(GestureRecognizerState.Status.self is any Error.Type) - XCTAssertFalse(GestureRecognizerState.Trace.self is any Codable.Type) - XCTAssertFalse(GestureRecognizerState.Trace.self is any RawRepresentable.Type) - XCTAssertFalse(GestureRecognizerState.Trace.self is any CaseIterable.Type) - XCTAssertFalse(GestureRecognizerState.Trace.self is any Error.Type) - XCTAssertFalse(GestureRecognizerState.Status.Lifecycle.self is any Codable.Type) - XCTAssertFalse(GestureRecognizerState.Status.Lifecycle.self is any RawRepresentable.Type) - XCTAssertFalse(GestureRecognizerState.Status.Lifecycle.self is any CaseIterable.Type) - XCTAssertFalse(GestureRecognizerState.Status.Lifecycle.self is any Error.Type) - XCTAssertFalse(GestureRecognizerFailure.self is any Codable.Type) - XCTAssertFalse(GestureRecognizerFailure.self is any RawRepresentable.Type) - XCTAssertFalse(GestureRecognizerFailure.self is any CaseIterable.Type) - XCTAssertFalse(GestureRecognizerFailure.self is any Error.Type) - } -} diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStateTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStateTests.swift new file mode 100644 index 0000000..05241fe --- /dev/null +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerStateTests.swift @@ -0,0 +1,120 @@ +import XCTest + +import PointerGestureKit + +final class GestureRecognizerStateTests: XCTestCase { + @MainActor + func testSnapshotExposesObservableStateToClients() { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration() + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + recognizer.isRecordingModeEnabled = true + recognizer.start() + + let state = recognizer.snapshot + XCTAssertEqual(state.status.lifecycle, .ready) + XCTAssertFalse(state.status.isCapturingGesture) + XCTAssertTrue(state.status.isRecordingModeEnabled) + XCTAssertTrue(state.status.lastRecordedDirections.isEmpty) + XCTAssertNil(state.status.lastFailure) + XCTAssertFalse(state.trace.isVisible) + XCTAssertTrue(state.trace.rawPoints.isEmpty) + XCTAssertTrue(state.trace.directions.isEmpty) + XCTAssertTrue(state.trace.directionEndpoints.isEmpty) + } + + @MainActor + func testSnapshotDefaultsToIdleObservableState() { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration() + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + let state = recognizer.snapshot + XCTAssertEqual(state.status.lifecycle, .idle) + XCTAssertFalse(state.status.isCapturingGesture) + XCTAssertFalse(state.status.isRecordingModeEnabled) + XCTAssertTrue(state.status.lastRecordedDirections.isEmpty) + XCTAssertNil(state.status.lastFailure) + XCTAssertFalse(state.trace.isVisible) + XCTAssertTrue(state.trace.rawPoints.isEmpty) + XCTAssertTrue(state.trace.directions.isEmpty) + XCTAssertTrue(state.trace.directionEndpoints.isEmpty) + } + + @MainActor + func testRecognizerStateAndFailureAreSendableValues() { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration() + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + let state = recognizer.snapshot + assertSendable(state) + assertSendable(state.status) + assertSendable(state.trace) + assertSendable(GestureRecognizerState.Status.Lifecycle.ready) + assertSendable(GestureRecognizerFailure.modifiersNotSatisfied) + } + + @MainActor + func testRecognizerStateLifecycleAndFailureAreEquatableValues() { + let eventSource = GestureEventSourceDouble(startResult: true) + let configuration: GestureRecognizerConfiguration = + makeGestureRecognizerTestConfiguration() + + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: configuration + ) + + let state = recognizer.snapshot + XCTAssertEqual(state, state) + XCTAssertEqual(state.status, state.status) + XCTAssertEqual(state.trace, state.trace) + XCTAssertNotEqual(GestureRecognizerState.Status.Lifecycle.idle, .ready) + XCTAssertNotEqual(GestureRecognizerFailure.eventSourceStartFailed, .gestureSessionExpired) + } + + func testTraceInitializerPreservesProvidedGeometry() { + let rawPoints = [ + GesturePoint(x: 0, y: 0), + GesturePoint(x: 10, y: 0), + GesturePoint(x: 20, y: 0), + GesturePoint(x: 20, y: 20), + ] + + let trimmedTrace = GestureRecognizerState.Trace( + rawPoints: rawPoints, + directions: [.right, .down], + directionEndpoints: rawPoints, + tailPoint: rawPoints[3] + ) + let paddedTrace = GestureRecognizerState.Trace( + rawPoints: rawPoints, + directions: [.right, .down], + directionEndpoints: [rawPoints[0]], + tailPoint: GesturePoint(x: 30, y: 20) + ) + + XCTAssertEqual(trimmedTrace.directionEndpoints, rawPoints) + XCTAssertEqual(trimmedTrace.tailPoint, rawPoints[3]) + XCTAssertEqual(paddedTrace.directionEndpoints, [rawPoints[0]]) + XCTAssertEqual(paddedTrace.tailPoint, GesturePoint(x: 30, y: 20)) + } + +} diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerTimingTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerTimingTests.swift index 05b39cc..793b3c2 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerTimingTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerTimingTests.swift @@ -5,31 +5,25 @@ import XCTest final class GestureRecognizerTimingTests: XCTestCase { func testTaskSleepNanosecondsRejectsNonPositiveAndNonFiniteIntervals() { - XCTAssertEqual(GestureRecognizerTiming.sleepNanoseconds(for: -1), 0) - XCTAssertEqual(GestureRecognizerTiming.sleepNanoseconds(for: 0), 0) - XCTAssertEqual(GestureRecognizerTiming.sleepNanoseconds(for: .nan), 0) - XCTAssertEqual(GestureRecognizerTiming.sleepNanoseconds(for: .infinity), 0) + XCTAssertEqual(GestureRecognizerTiming.taskSleepNanoseconds(for: -1), 0) + XCTAssertEqual(GestureRecognizerTiming.taskSleepNanoseconds(for: 0), 0) + XCTAssertEqual(GestureRecognizerTiming.taskSleepNanoseconds(for: .nan), 0) + XCTAssertEqual(GestureRecognizerTiming.taskSleepNanoseconds(for: .infinity), 0) } func testTaskSleepNanosecondsRoundsPositiveIntervalsUp() { - XCTAssertEqual(GestureRecognizerTiming.sleepNanoseconds(for: 0.000_000_000_1), 1) + XCTAssertEqual(GestureRecognizerTiming.taskSleepNanoseconds(for: 0.000_000_000_1), 1) + XCTAssertEqual(GestureRecognizerTiming.taskSleepNanoseconds(for: 1.25), 1_250_000_000) XCTAssertEqual( - GestureRecognizerTiming.sleepNanoseconds(for: 1.000_000_000_1), + GestureRecognizerTiming.taskSleepNanoseconds(for: 1.000_000_000_1), 1_000_000_001 ) - XCTAssertEqual(GestureRecognizerTiming.sleepNanoseconds(for: 1.25), 1_250_000_000) } func testTaskSleepNanosecondsClampsOverflow() { XCTAssertEqual( - GestureRecognizerTiming.sleepNanoseconds(for: .greatestFiniteMagnitude), + GestureRecognizerTiming.taskSleepNanoseconds(for: .greatestFiniteMagnitude), UInt64.max ) } - - func testTaskSleepNanosecondsClampsUInt64Boundary() { - let boundary = TimeInterval(UInt64.max) / 1_000_000_000 - - XCTAssertEqual(GestureRecognizerTiming.sleepNanoseconds(for: boundary), UInt64.max) - } } diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerTuningTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerTuningTests.swift index 4d11a14..2beec5a 100644 --- a/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerTuningTests.swift +++ b/Tests/PointerGestureKitTests/Recognizer/GestureRecognizerTuningTests.swift @@ -2,8 +2,8 @@ import PointerGestureKit import XCTest final class GestureRecognizerTuningTests: XCTestCase { - func testInitializerPreservesValidCustomValues() { - let tuning = GestureRecognizerTuning( + func testValidatedPreservesCustomValues() throws { + let tuning = try GestureRecognizerTuning.validated( minimumGestureStartAxisDistance: 1.5, minimumDirectionChangeAxisDistance: 2.5, maximumGestureSessionDuration: nil, @@ -18,108 +18,130 @@ final class GestureRecognizerTuningTests: XCTestCase { XCTAssertEqual(tuning.eventSourceStartRetryDelays, [0, 0.125, 3]) } - func testMaximumRawPointCountIsAtLeastOne() { - XCTAssertEqual( - GestureRecognizerTuning(maximumRawPointCount: 0).maximumRawPointCount, 1) - XCTAssertEqual( - GestureRecognizerTuning(maximumRawPointCount: -10).maximumRawPointCount, 1) + func testValidatedRejectsInvalidDistances() { + XCTAssertThrowsError( + try GestureRecognizerTuning.validated(minimumGestureStartAxisDistance: -1) + ) { error in + XCTAssertEqual( + error as? GestureRecognizerTuning.ValidationError, + .invalidMinimumGestureStartAxisDistance + ) + } + XCTAssertThrowsError( + try GestureRecognizerTuning.validated(minimumDirectionChangeAxisDistance: .nan) + ) { error in + XCTAssertEqual( + error as? GestureRecognizerTuning.ValidationError, + .invalidMinimumDirectionChangeAxisDistance + ) + } } - func testEventSourceStartRetryDelaysDiscardInvalidValuesAndPreserveExplicitZero() { - let tuning = GestureRecognizerTuning( - eventSourceStartRetryDelays: [-1, .nan, .infinity, 0, 0.25] - ) - - XCTAssertEqual(tuning.eventSourceStartRetryDelays, [0, 0.25]) - } - - func testInvalidOnlyEventSourceStartRetryDelaysBecomeEmpty() { - let tuning = GestureRecognizerTuning( - eventSourceStartRetryDelays: [-1, .nan, .infinity] - ) - - XCTAssertTrue(tuning.eventSourceStartRetryDelays.isEmpty) + func testValidatedRejectsInvalidDurationAndPointCount() { + XCTAssertThrowsError( + try GestureRecognizerTuning.validated(maximumGestureSessionDuration: 0) + ) { error in + XCTAssertEqual( + error as? GestureRecognizerTuning.ValidationError, + .invalidMaximumGestureSessionDuration + ) + } + XCTAssertThrowsError(try GestureRecognizerTuning.validated(maximumRawPointCount: 0)) { error in + XCTAssertEqual( + error as? GestureRecognizerTuning.ValidationError, + .invalidMaximumRawPointCount + ) + } } - func testEmptyEventSourceStartRetryDelaysArePreserved() { - let tuning = GestureRecognizerTuning(eventSourceStartRetryDelays: []) - - XCTAssertTrue(tuning.eventSourceStartRetryDelays.isEmpty) - } - - func testRecognitionAxisDistancesAreNonNegativeAndFinite() { - let tuning = GestureRecognizerTuning( - minimumGestureStartAxisDistance: -.infinity, - minimumDirectionChangeAxisDistance: .nan, - maximumGestureSessionDuration: -1 - ) - - XCTAssertEqual(tuning.minimumGestureStartAxisDistance, 0) - XCTAssertEqual(tuning.minimumDirectionChangeAxisDistance, 0) - XCTAssertNil(tuning.maximumGestureSessionDuration) + func testValidatedReportsInvalidRetryDelayIndex() { + XCTAssertThrowsError( + try GestureRecognizerTuning.validated(eventSourceStartRetryDelays: [0.1, .infinity]) + ) { error in + XCTAssertEqual( + error as? GestureRecognizerTuning.ValidationError, + .invalidEventSourceStartRetryDelay(index: 1) + ) + } } - func testRecognitionAxisDistancesRejectNegativeValues() { - let tuning = GestureRecognizerTuning( - minimumGestureStartAxisDistance: -1, - minimumDirectionChangeAxisDistance: -2 + func testValidatedPreservesRetryOrderDuplicatesAndZero() throws { + let tuning = try GestureRecognizerTuning.validated( + eventSourceStartRetryDelays: [1, 0.25, 1, 0] ) - - XCTAssertEqual(tuning.minimumGestureStartAxisDistance, 0) - XCTAssertEqual(tuning.minimumDirectionChangeAxisDistance, 0) - } - - func testMaximumGestureSessionDurationKeepsPositiveFiniteValue() { - let tuning = GestureRecognizerTuning(maximumGestureSessionDuration: 1.25) - - XCTAssertEqual(tuning.maximumGestureSessionDuration, 1.25) + XCTAssertEqual(tuning.eventSourceStartRetryDelays, [1, 0.25, 1, 0]) } - func testMaximumGestureSessionDurationRejectsNonPositiveAndNonFiniteValues() { - XCTAssertNil( - GestureRecognizerTuning(maximumGestureSessionDuration: -1) - .maximumGestureSessionDuration) - XCTAssertNil( - GestureRecognizerTuning(maximumGestureSessionDuration: 0) - .maximumGestureSessionDuration) - XCTAssertNil( - GestureRecognizerTuning(maximumGestureSessionDuration: .nan) - .maximumGestureSessionDuration) - XCTAssertNil( - GestureRecognizerTuning(maximumGestureSessionDuration: .infinity) - .maximumGestureSessionDuration) - } - - func testDefaultTuningValuesAreStable() { - let tuning = GestureRecognizerTuning() - + func testStandardValuesAreExpected() { + let tuning = GestureRecognizerTuning.standard XCTAssertEqual(tuning.minimumGestureStartAxisDistance, 10) XCTAssertEqual(tuning.minimumDirectionChangeAxisDistance, 25) - XCTAssertEqual(tuning.maximumGestureSessionDuration, 10) - XCTAssertEqual(tuning.maximumRawPointCount, 1000) + XCTAssertEqual(tuning.maximumGestureSessionDuration, 1) + XCTAssertEqual(tuning.maximumRawPointCount, 256) XCTAssertEqual(tuning.eventSourceStartRetryDelays, [0.25, 0.5, 1, 2, 4]) } - func testTuningDoesNotExposeSerializationOrEnumerationContracts() { - XCTAssertFalse(GestureRecognizerTuning.self is any Codable.Type) - XCTAssertFalse(GestureRecognizerTuning.self is any RawRepresentable.Type) - XCTAssertFalse(GestureRecognizerTuning.self is any CaseIterable.Type) - XCTAssertFalse(GestureRecognizerTuning.self is any Error.Type) + func testTuningIsEquatableAndSendable() throws { + let tuning = try GestureRecognizerTuning.validated(maximumGestureSessionDuration: nil) + assertSendable(tuning) + assertSendable(GestureRecognizerTuning.ValidationError.invalidMaximumRawPointCount) + XCTAssertEqual(tuning, tuning) } - func testTuningIsHashableValue() { - let tuning = GestureRecognizerTuning( - minimumGestureStartAxisDistance: 1, - minimumDirectionChangeAxisDistance: 2, - maximumGestureSessionDuration: 3, - maximumRawPointCount: 4, - eventSourceStartRetryDelays: [5] + @MainActor + func testUpdateTuningAppliesToFutureGestureSessions() { + let eventSource = GestureEventSourceDouble(startResult: true) + var matcher = GesturePatternMatcher() + matcher.register(pattern: [.right], match: UUID()) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: makeGestureRecognizerTestConfiguration( + makeMatcher: { _ in matcher }, + tuning: .testing( + minimumGestureStartAxisDistance: 100, + minimumDirectionChangeAxisDistance: 0 + ) + ) ) - XCTAssertEqual(Set([tuning, tuning]).count, 1) + recognizer.start() + _ = eventSource.send(makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero)) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 50, y: 0))) + XCTAssertFalse(recognizer.snapshot.status.isCapturingGesture) + + recognizer.cancelActiveGesture() + recognizer.updateTuning( + .testing( + minimumGestureStartAxisDistance: 10, + minimumDirectionChangeAxisDistance: 0 + )) + _ = eventSource.send(makeGestureInputEvent(kind: .buttonDown(.secondary), location: .zero)) + _ = eventSource.send( + makeGestureInputEvent(kind: .buttonMoved(.secondary), location: .init(x: 50, y: 0))) + + XCTAssertTrue(recognizer.snapshot.status.isCapturingGesture) + XCTAssertEqual(recognizer.snapshot.trace.directions, [.right]) } - func testTuningIsSendableValue() { - assertSendable(GestureRecognizerTuning()) + @MainActor + func testUpdateTuningReportsWhetherTuningChanged() { + let eventSource = GestureEventSourceDouble(startResult: true) + let initialTuning = GestureRecognizerTuning.testing( + minimumGestureStartAxisDistance: 100, + minimumDirectionChangeAxisDistance: 0 + ) + let recognizer = GestureRecognizer( + eventSource: eventSource, + configuration: makeGestureRecognizerTestConfiguration(tuning: initialTuning) + ) + + XCTAssertFalse(recognizer.updateTuning(initialTuning)) + XCTAssertTrue( + recognizer.updateTuning( + .testing( + minimumGestureStartAxisDistance: 10, + minimumDirectionChangeAxisDistance: 0 + ))) } } diff --git a/Tests/PointerGestureKitTests/Recognizer/GestureTraceEndpointNormalizerTests.swift b/Tests/PointerGestureKitTests/Recognizer/GestureTraceEndpointNormalizerTests.swift deleted file mode 100644 index 7c8eda9..0000000 --- a/Tests/PointerGestureKitTests/Recognizer/GestureTraceEndpointNormalizerTests.swift +++ /dev/null @@ -1,226 +0,0 @@ -import XCTest - -@testable import PointerGestureKit - -final class GestureTraceEndpointNormalizerTests: XCTestCase { - func testReturnsEndpointsWhenTheyAlreadyMatchRecognizedDirectionCount() { - let endpoints: [GesturePoint] = [ - GesturePoint(x: 0, y: 0), - GesturePoint(x: 10, y: 0), - GesturePoint(x: 10, y: 10), - ] - let publishedRawPoints: [GesturePoint] = [ - GesturePoint(x: 100, y: 100), - GesturePoint(x: 200, y: 200), - ] - - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: endpoints, - recognizedDirectionCount: 2, - publishedRawPoints: publishedRawPoints - ) - - XCTAssertEqual(result, endpoints) - } - - func testTrimsExcessPointsKeepingPrefixAndLastPoint() { - let recognizedDirectionCount = 2 - let endpoints: [GesturePoint] = [ - GesturePoint(x: 0, y: 0), - GesturePoint(x: 10, y: 0), - GesturePoint(x: 20, y: 0), - GesturePoint(x: 20, y: 10), - GesturePoint(x: 20, y: 20), - ] - - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: endpoints, - recognizedDirectionCount: recognizedDirectionCount, - publishedRawPoints: endpoints - ) - - XCTAssertEqual(result.count, recognizedDirectionCount + 1) - XCTAssertEqual(result[0], endpoints[0]) - XCTAssertEqual(result[1], endpoints[1]) - XCTAssertEqual(result[2], endpoints.last) - } - - func testTrimsExcessPointsForEmptyDirectionsToFirstPoint() { - let endpoints: [GesturePoint] = [ - GesturePoint(x: 0, y: 0), - GesturePoint(x: 10, y: 0), - ] - - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: endpoints, - recognizedDirectionCount: 0, - publishedRawPoints: endpoints - ) - - XCTAssertEqual(result, [endpoints[0]]) - } - - func testFillsMissingPointsFromPublishedRawPointsSkippingDuplicates() { - let recognizedDirectionCount = 2 - let endpoints: [GesturePoint] = [GesturePoint(x: 0, y: 0)] - let publishedRawPoints: [GesturePoint] = [ - GesturePoint(x: 0, y: 0), - GesturePoint(x: 0, y: 0), - GesturePoint(x: 10, y: 0), - GesturePoint(x: 10, y: 10), - ] - - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: endpoints, - recognizedDirectionCount: recognizedDirectionCount, - publishedRawPoints: publishedRawPoints - ) - - XCTAssertEqual(result, [publishedRawPoints[0], publishedRawPoints[2], publishedRawPoints[3]]) - } - - func testFillsMissingPointsOnlyAfterLastEndpoint() { - let recognizedDirectionCount = 2 - let endpoints: [GesturePoint] = [GesturePoint(x: 10, y: 0)] - let publishedRawPoints: [GesturePoint] = [ - GesturePoint(x: 0, y: 0), - GesturePoint(x: 10, y: 0), - GesturePoint(x: 10, y: 10), - GesturePoint(x: 20, y: 10), - ] - - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: endpoints, - recognizedDirectionCount: recognizedDirectionCount, - publishedRawPoints: publishedRawPoints - ) - - XCTAssertEqual(result, [publishedRawPoints[1], publishedRawPoints[2], publishedRawPoints[3]]) - } - - func testFillsMissingPointsAfterMatchedEndpointSequence() { - let recognizedDirectionCount = 3 - let endpoints: [GesturePoint] = [ - GesturePoint(x: 0, y: 0), - GesturePoint(x: 10, y: 0), - ] - let publishedRawPoints: [GesturePoint] = [ - GesturePoint(x: 0, y: 0), - GesturePoint(x: 10, y: 0), - GesturePoint(x: 0, y: 0), - GesturePoint(x: 10, y: 0), - GesturePoint(x: 10, y: 10), - ] - - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: endpoints, - recognizedDirectionCount: recognizedDirectionCount, - publishedRawPoints: publishedRawPoints - ) - - XCTAssertEqual( - result, - [publishedRawPoints[0], publishedRawPoints[1], publishedRawPoints[2], publishedRawPoints[3]] - ) - } - - func testFillsMissingPointsAfterPartialEndpointMatch() { - let recognizedDirectionCount = 3 - let endpoints: [GesturePoint] = [ - GesturePoint(x: 0, y: 0), - GesturePoint(x: 99, y: 99), - ] - let publishedRawPoints: [GesturePoint] = [ - GesturePoint(x: 0, y: 0), - GesturePoint(x: 10, y: 0), - GesturePoint(x: 20, y: 0), - ] - - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: endpoints, - recognizedDirectionCount: recognizedDirectionCount, - publishedRawPoints: publishedRawPoints - ) - - XCTAssertEqual( - result, - [endpoints[0], endpoints[1], publishedRawPoints[1], publishedRawPoints[2]] - ) - } - - func testEmptyEndpointsFallsBackToFirstRawPoint() { - let publishedRawPoints: [GesturePoint] = [GesturePoint(x: 5, y: 5)] - - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: [], - recognizedDirectionCount: 0, - publishedRawPoints: publishedRawPoints - ) - - XCTAssertEqual(result, [publishedRawPoints[0]]) - } - - func testRepeatsLastAvailablePointWhenPublishedRawPointsDoNotFillTarget() { - let recognizedDirectionCount = 2 - let endpoints: [GesturePoint] = [GesturePoint(x: 0, y: 0)] - let publishedRawPoints: [GesturePoint] = [ - GesturePoint(x: 0, y: 0), - GesturePoint(x: 10, y: 0), - ] - - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: endpoints, - recognizedDirectionCount: recognizedDirectionCount, - publishedRawPoints: publishedRawPoints - ) - - XCTAssertEqual(result, [publishedRawPoints[0], publishedRawPoints[1], publishedRawPoints[1]]) - } - - func testRepeatsLastEndpointWhenRawPointsAreUnavailable() { - let endpoints: [GesturePoint] = [ - GesturePoint(x: 0, y: 0), - GesturePoint(x: 10, y: 0), - ] - - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: endpoints, - recognizedDirectionCount: 3, - publishedRawPoints: [] - ) - - XCTAssertEqual(result, [endpoints[0], endpoints[1], endpoints[1], endpoints[1]]) - } - - func testReturnsEmptyWhenNoPointsAreAvailable() { - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: [], - recognizedDirectionCount: 1, - publishedRawPoints: [] - ) - - XCTAssertTrue(result.isEmpty) - } - - func testReturnsEmptyForNegativeRecognizedDirectionCount() { - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: [GesturePoint(x: 0, y: 0)], - recognizedDirectionCount: -1, - publishedRawPoints: [GesturePoint(x: 0, y: 0)] - ) - - XCTAssertTrue(result.isEmpty) - } - - func testLeavesEndpointsUnchangedWhenRecognizedDirectionCountCannotFitEndpointCount() { - let endpoints = [GesturePoint(x: 0, y: 0), GesturePoint(x: 10, y: 0)] - - let result = GestureTraceEndpointNormalizer.normalizedEndpoints( - endpoints: endpoints, - recognizedDirectionCount: Int.max, - publishedRawPoints: endpoints - ) - - XCTAssertEqual(result, endpoints) - } -} diff --git a/Tests/PointerGestureKitTests/Support/GestureEventDispositionAssertions.swift b/Tests/PointerGestureKitTests/Support/GestureEventDispositionAssertions.swift new file mode 100644 index 0000000..f31df81 --- /dev/null +++ b/Tests/PointerGestureKitTests/Support/GestureEventDispositionAssertions.swift @@ -0,0 +1,11 @@ +import PointerGestureKit +import XCTest + +func assertDisposition( + _ disposition: @autoclosure () -> GestureEventDisposition, + _ expected: GestureEventDisposition, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertEqual(disposition(), expected, file: file, line: line) +} diff --git a/Tests/PointerGestureKitTests/Support/GestureRecognizerTestConfiguration.swift b/Tests/PointerGestureKitTests/Support/GestureRecognizerTestConfiguration.swift index 7d054f6..ce67a48 100644 --- a/Tests/PointerGestureKitTests/Support/GestureRecognizerTestConfiguration.swift +++ b/Tests/PointerGestureKitTests/Support/GestureRecognizerTestConfiguration.swift @@ -14,6 +14,7 @@ func makeGestureRecognizerTestConfiguration( isRecognitionEnabled: @escaping @MainActor @Sendable (GestureRecognitionContext?) -> Bool = { _ in true }, + passesThroughEmptyMatcher: Bool = false, areModifiersSatisfied: @escaping @MainActor @Sendable ( GestureModifierFlags, @@ -28,6 +29,7 @@ func makeGestureRecognizerTestConfiguration( recognitionButton: recognitionButton, recognitionContext: recognitionContext, isRecognitionEnabled: isRecognitionEnabled, + passesThroughEmptyMatcher: passesThroughEmptyMatcher, areModifiersSatisfied: areModifiersSatisfied, tuning: tuning ) diff --git a/Tests/PointerGestureKitTests/Support/GestureRecognizerTuning+TestSupport.swift b/Tests/PointerGestureKitTests/Support/GestureRecognizerTuning+TestSupport.swift index aadf095..221a094 100644 --- a/Tests/PointerGestureKitTests/Support/GestureRecognizerTuning+TestSupport.swift +++ b/Tests/PointerGestureKitTests/Support/GestureRecognizerTuning+TestSupport.swift @@ -9,7 +9,7 @@ extension GestureRecognizerTuning { maximumRawPointCount: Int = 1000, eventSourceStartRetryDelays: [TimeInterval] = [] ) -> Self { - Self( + try! Self.validated( minimumGestureStartAxisDistance: minimumGestureStartAxisDistance, minimumDirectionChangeAxisDistance: minimumDirectionChangeAxisDistance, maximumGestureSessionDuration: maximumGestureSessionDuration, diff --git a/Tests/PointerGestureKitTests/Support/GestureReplayRequestAssertions.swift b/Tests/PointerGestureKitTests/Support/GestureReplayRequestAssertions.swift new file mode 100644 index 0000000..1711840 --- /dev/null +++ b/Tests/PointerGestureKitTests/Support/GestureReplayRequestAssertions.swift @@ -0,0 +1,11 @@ +import PointerGestureKit +import XCTest + +func assertReplayRequests( + _ requests: [GestureReplayRequest], + _ expected: [GestureReplayRequest], + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertEqual(requests, expected, file: file, line: line) +} From 4e19e9bad1d06a431d5b7eefd96ac63475169844 Mon Sep 17 00:00:00 2001 From: Haruki Fukui Date: Sun, 12 Jul 2026 05:37:57 +0900 Subject: [PATCH 2/3] Fix Swift 6.2 event tap teardown --- CHANGELOG.md | 2 + .../EventTap/GestureEventTap.swift | 65 ++++++++++++++++--- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07fd89c..ea086c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ No changes yet. - Raised the package requirement to Swift 6.2 for actor-isolated resource cleanup. - Moved recognizer deinitialization cleanup into a non-generic lifetime owner so optimized Release builds preserve replay and event-source shutdown behavior without triggering a compiler crash. +- Kept Core Graphics event-tap teardown compatible with Swift 6.2 runtimes that abort when the + public tap type uses an actor-isolated deinitializer. - Updated installation guidance to use the 0.2.0 release line. - Removed the redundant `GesturePatternCatalog` and `GesturePatternValidator` wrappers. Pattern registration reports an empty pattern directly; duplicate registration intentionally replaces diff --git a/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap.swift b/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap.swift index bc1c71e..e9e6cd3 100644 --- a/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap.swift +++ b/Sources/PointerGestureKitCoreGraphics/EventTap/GestureEventTap.swift @@ -10,8 +10,8 @@ import PointerGestureKit @MainActor public final class GestureEventTap: GestureEventSource { let capturedButtons: Set - private var tapPort: CFMachPort? - private var runLoopSource: CFRunLoopSource? + private var tapPort: EventTapPort? + private var runLoopSource: EventTapRunLoopSource? private var eventHandler: (@MainActor @Sendable (GestureInputEvent) -> GestureEventDisposition)? var syntheticEventSuppression = SyntheticEventSuppression() @@ -22,8 +22,21 @@ public final class GestureEventTap: GestureEventSource { self.capturedButtons = capturedButtons } - isolated deinit { - stop() + deinit { + let cleanup = GestureEventTapDeinitCleanup( + tapPort: tapPort, + runLoopSource: runLoopSource + ) + + if Thread.isMainThread { + MainActor.assumeIsolated { + cleanup.run() + } + } else { + Task { @MainActor in + cleanup.run() + } + } } /// Starts the macOS event tap. @@ -52,12 +65,12 @@ public final class GestureEventTap: GestureEventSource { /// Calling this while the tap is already stopped is safe. public func stop() { if let source = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + CFRunLoopRemoveSource(CFRunLoopGetMain(), source.value, .commonModes) runLoopSource = nil } if let port = tapPort { - CGEvent.tapEnable(tap: port, enable: false) - CFMachPortInvalidate(port) + CGEvent.tapEnable(tap: port.value, enable: false) + CFMachPortInvalidate(port.value) tapPort = nil } eventHandler = nil @@ -66,7 +79,7 @@ public final class GestureEventTap: GestureEventSource { private func reenableTapPort() { guard let port = tapPort else { return } - CGEvent.tapEnable(tap: port, enable: true) + CGEvent.tapEnable(tap: port.value, enable: true) } private func makeTapPort() -> CFMachPort? { @@ -91,8 +104,8 @@ public final class GestureEventTap: GestureEventSource { ) { eventHandler = handler CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) - tapPort = port - runLoopSource = source + tapPort = EventTapPort(port) + runLoopSource = EventTapRunLoopSource(source) CGEvent.tapEnable(tap: port, enable: true) } @@ -168,3 +181,35 @@ public final class GestureEventTap: GestureEventSource { reenableTapPort() } } + +private struct GestureEventTapDeinitCleanup: @unchecked Sendable { + let tapPort: EventTapPort? + let runLoopSource: EventTapRunLoopSource? + + @MainActor + func run() { + if let runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource.value, .commonModes) + } + if let tapPort { + CGEvent.tapEnable(tap: tapPort.value, enable: false) + CFMachPortInvalidate(tapPort.value) + } + } +} + +private struct EventTapPort: @unchecked Sendable { + let value: CFMachPort + + init(_ value: CFMachPort) { + self.value = value + } +} + +private struct EventTapRunLoopSource: @unchecked Sendable { + let value: CFRunLoopSource + + init(_ value: CFRunLoopSource) { + self.value = value + } +} From b10a232b0d6b36cd557dd5cd667b381d68004f5e Mon Sep 17 00:00:00 2001 From: Haruki Fukui Date: Sun, 12 Jul 2026 05:40:27 +0900 Subject: [PATCH 3/3] Complete Swift 6.2 deinit compatibility --- CHANGELOG.md | 7 +-- .../Observation/GestureObservationToken.swift | 41 ++++++++++++++--- .../Recognizer/GestureRecognizerCleanup.swift | 46 +++++++++++++++++-- 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea086c1..169a3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,12 @@ No changes yet. ### Changed -- Raised the package requirement to Swift 6.2 for actor-isolated resource cleanup. +- Raised the package requirement to Swift 6.2 and kept lifetime cleanup compatible with Swift 6.2 + runtimes. - Moved recognizer deinitialization cleanup into a non-generic lifetime owner so optimized Release builds preserve replay and event-source shutdown behavior without triggering a compiler crash. -- Kept Core Graphics event-tap teardown compatible with Swift 6.2 runtimes that abort when the - public tap type uses an actor-isolated deinitializer. +- Avoided actor-isolated deinitializers that abort under Swift 6.2.4 while preserving synchronous + main-thread cleanup and safe main-actor handoff from other threads. - Updated installation guidance to use the 0.2.0 release line. - Removed the redundant `GesturePatternCatalog` and `GesturePatternValidator` wrappers. Pattern registration reports an empty pattern directly; duplicate registration intentionally replaces diff --git a/Sources/PointerGestureKit/Observation/GestureObservationToken.swift b/Sources/PointerGestureKit/Observation/GestureObservationToken.swift index 588af46..cab5313 100644 --- a/Sources/PointerGestureKit/Observation/GestureObservationToken.swift +++ b/Sources/PointerGestureKit/Observation/GestureObservationToken.swift @@ -1,20 +1,47 @@ +import Foundation + /// A cancellable token that keeps a recognizer observation active. @MainActor public final class GestureObservationToken { - private var onCancel: (@MainActor () -> Void)? + private let cleanup: GestureObservationTokenCleanup + + init(onCancel: @escaping @MainActor @Sendable () -> Void) { + cleanup = GestureObservationTokenCleanup(onCancel: onCancel) + } + + /// Cancels the observation if it is still active. + public func cancel() { + cleanup.cancel() + } +} + +private final class GestureObservationTokenCleanup: @unchecked Sendable { + private let lock = NSLock() + private var onCancel: (@MainActor @Sendable () -> Void)? - init(onCancel: @escaping @MainActor () -> Void) { + init(onCancel: @escaping @MainActor @Sendable () -> Void) { self.onCancel = onCancel } - isolated deinit { + deinit { cancel() } - /// Cancels the observation if it is still active. - public func cancel() { - guard let onCancel else { return } + func cancel() { + lock.lock() + let onCancel = onCancel self.onCancel = nil - onCancel() + lock.unlock() + + guard let onCancel else { return } + if Thread.isMainThread { + MainActor.assumeIsolated { + onCancel() + } + } else { + Task { @MainActor in + onCancel() + } + } } } diff --git a/Sources/PointerGestureKit/Recognizer/GestureRecognizerCleanup.swift b/Sources/PointerGestureKit/Recognizer/GestureRecognizerCleanup.swift index 5d8b176..e38da02 100644 --- a/Sources/PointerGestureKit/Recognizer/GestureRecognizerCleanup.swift +++ b/Sources/PointerGestureKit/Recognizer/GestureRecognizerCleanup.swift @@ -1,25 +1,61 @@ -@MainActor +import Foundation + final class GestureRecognizerCleanup { - let eventSource: any GestureEventSource + private let eventSource: GestureEventSourceReference let onReplayRequested: @MainActor @Sendable (GestureReplayRequest) -> Void var startupRetryTask: Task? var inputExpirationTask: Task? var replayRequest: GestureReplayRequest? + @MainActor init( eventSource: any GestureEventSource, onReplayRequested: @escaping @MainActor @Sendable (GestureReplayRequest) -> Void ) { - self.eventSource = eventSource + self.eventSource = GestureEventSourceReference(eventSource) self.onReplayRequested = onReplayRequested } - isolated deinit { + deinit { startupRetryTask?.cancel() inputExpirationTask?.cancel() + + let cleanup = GestureRecognizerDeinitCleanup( + eventSource: eventSource, + onReplayRequested: onReplayRequested, + replayRequest: replayRequest + ) + if Thread.isMainThread { + MainActor.assumeIsolated { + cleanup.run() + } + } else { + Task { @MainActor in + cleanup.run() + } + } + } +} + +private struct GestureRecognizerDeinitCleanup: Sendable { + let eventSource: GestureEventSourceReference + let onReplayRequested: @MainActor @Sendable (GestureReplayRequest) -> Void + let replayRequest: GestureReplayRequest? + + @MainActor + func run() { if let replayRequest { onReplayRequested(replayRequest) } - eventSource.stop() + eventSource.value.stop() + } +} + +private struct GestureEventSourceReference: @unchecked Sendable { + let value: any GestureEventSource + + @MainActor + init(_ value: any GestureEventSource) { + self.value = value } }