From aa56fedd7840528b31c18a6b1803544e4ba3009b Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 5 May 2026 18:32:31 +0800 Subject: [PATCH 01/20] Update folder structure --- .../Component/{ => Components}/LongPressComponent.swift | 0 .../OpenGestures/Component/{ => Components}/PanComponent.swift | 0 .../OpenGestures/Component/{ => Components}/TapComponent.swift | 0 Sources/OpenGestures/Component/{ => Gate}/DiscreteGate.swift | 0 Sources/OpenGestures/Component/{ => Gate}/MovementGate.swift | 0 Sources/OpenGestures/Component/{ => Track}/TrackedValue.swift | 0 Sources/OpenGestures/Component/{ => Track}/UpdateTracer.swift | 0 Sources/OpenGestures/Component/{ => Track}/ValueTracker.swift | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename Sources/OpenGestures/Component/{ => Components}/LongPressComponent.swift (100%) rename Sources/OpenGestures/Component/{ => Components}/PanComponent.swift (100%) rename Sources/OpenGestures/Component/{ => Components}/TapComponent.swift (100%) rename Sources/OpenGestures/Component/{ => Gate}/DiscreteGate.swift (100%) rename Sources/OpenGestures/Component/{ => Gate}/MovementGate.swift (100%) rename Sources/OpenGestures/Component/{ => Track}/TrackedValue.swift (100%) rename Sources/OpenGestures/Component/{ => Track}/UpdateTracer.swift (100%) rename Sources/OpenGestures/Component/{ => Track}/ValueTracker.swift (100%) diff --git a/Sources/OpenGestures/Component/LongPressComponent.swift b/Sources/OpenGestures/Component/Components/LongPressComponent.swift similarity index 100% rename from Sources/OpenGestures/Component/LongPressComponent.swift rename to Sources/OpenGestures/Component/Components/LongPressComponent.swift diff --git a/Sources/OpenGestures/Component/PanComponent.swift b/Sources/OpenGestures/Component/Components/PanComponent.swift similarity index 100% rename from Sources/OpenGestures/Component/PanComponent.swift rename to Sources/OpenGestures/Component/Components/PanComponent.swift diff --git a/Sources/OpenGestures/Component/TapComponent.swift b/Sources/OpenGestures/Component/Components/TapComponent.swift similarity index 100% rename from Sources/OpenGestures/Component/TapComponent.swift rename to Sources/OpenGestures/Component/Components/TapComponent.swift diff --git a/Sources/OpenGestures/Component/DiscreteGate.swift b/Sources/OpenGestures/Component/Gate/DiscreteGate.swift similarity index 100% rename from Sources/OpenGestures/Component/DiscreteGate.swift rename to Sources/OpenGestures/Component/Gate/DiscreteGate.swift diff --git a/Sources/OpenGestures/Component/MovementGate.swift b/Sources/OpenGestures/Component/Gate/MovementGate.swift similarity index 100% rename from Sources/OpenGestures/Component/MovementGate.swift rename to Sources/OpenGestures/Component/Gate/MovementGate.swift diff --git a/Sources/OpenGestures/Component/TrackedValue.swift b/Sources/OpenGestures/Component/Track/TrackedValue.swift similarity index 100% rename from Sources/OpenGestures/Component/TrackedValue.swift rename to Sources/OpenGestures/Component/Track/TrackedValue.swift diff --git a/Sources/OpenGestures/Component/UpdateTracer.swift b/Sources/OpenGestures/Component/Track/UpdateTracer.swift similarity index 100% rename from Sources/OpenGestures/Component/UpdateTracer.swift rename to Sources/OpenGestures/Component/Track/UpdateTracer.swift diff --git a/Sources/OpenGestures/Component/ValueTracker.swift b/Sources/OpenGestures/Component/Track/ValueTracker.swift similarity index 100% rename from Sources/OpenGestures/Component/ValueTracker.swift rename to Sources/OpenGestures/Component/Track/ValueTracker.swift From 996a74205bc352bc40249e1195f3ba13e66a00ac Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 5 May 2026 20:10:57 +0800 Subject: [PATCH 02/20] Add Expiration --- .../OpenGestures/Component/Expiration.swift | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 Sources/OpenGestures/Component/Expiration.swift diff --git a/Sources/OpenGestures/Component/Expiration.swift b/Sources/OpenGestures/Component/Expiration.swift new file mode 100644 index 0000000..6b27bac --- /dev/null +++ b/Sources/OpenGestures/Component/Expiration.swift @@ -0,0 +1,39 @@ +// +// Expiration.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - Expiration + +package struct Expiration: Sendable { + package var deadline: Timestamp + package var reason: ExpirationReason + + package init( + deadline: Timestamp, + reason: ExpirationReason + ) { + self.deadline = deadline + self.reason = reason + } +} + +// MARK: - ExpirationReason + +package struct ExpirationReason: ExpressibleByStringLiteral, CustomStringConvertible, Sendable { + package let rawValue: String + + package init(rawValue: String) { + self.rawValue = rawValue + } + + package init(stringLiteral value: String) { + self.rawValue = value + } + + package var description: String { + rawValue + } +} From 9f139cfccaed1baed890ca817a1bbea5cb778b82 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 5 May 2026 21:41:52 +0800 Subject: [PATCH 03/20] Add ExpirationComponent --- .../Components/ExpirationComponent.swift | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 Sources/OpenGestures/Component/Components/ExpirationComponent.swift diff --git a/Sources/OpenGestures/Component/Components/ExpirationComponent.swift b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift new file mode 100644 index 0000000..ee1c28f --- /dev/null +++ b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift @@ -0,0 +1,213 @@ +// +// ExpirationComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: WIP + +import Synchronization + +// MARK: - Expirable + +package protocol Expirable: Sendable { + associatedtype Value: Sendable + + var payload: ExpirablePayload { get } + + var expiration: Expiration? { get } +} + +// MARK: - ExpirationComponent + +package struct ExpirationComponent: Sendable where Upstream: GestureComponent, Upstream.Value: Expirable { + + package var upstream: Upstream + + package struct State: NestedCustomStringConvertible, Sendable { + package var request: UpdateRequest? + + package init() { + request = nil + } + + package init(request: UpdateRequest?) { + self.request = request + } + } + + package var state: State + + package init( + upstream: Upstream, + state: State = .init() + ) { + self.upstream = upstream + self.state = state + } + + package enum Failure: Error, Sendable { + case timeout(reason: ExpirationReason) + } +} + +// MARK: - ExpirationComponent + CompositeGestureComponent + +extension ExpirationComponent: CompositeGestureComponent {} + +// MARK: - ExpirationComponent + StatefulGestureComponent + +extension ExpirationComponent: StatefulGestureComponent {} + +// MARK: - ExpirationComponent.State + GestureComponentState + +extension ExpirationComponent.State: GestureComponentState {} + +// MARK: - ExpirationComponent + ValueTransformingComponent + +extension ExpirationComponent: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + let metadata = try metadata( + for: value.expiration, + context: context + ) + + switch value.payload { + case let .empty(reason): + return .empty(reason, metadata: metadata) + case let .value(payload): + if isFinal { + return .finalValue(payload, metadata: metadata) + } else { + return .value(payload, metadata: metadata) + } + } + } + + private mutating func metadata( + for expiration: Expiration?, + context: GestureComponentContext + ) throws -> GestureOutputMetadata? { + var updatesToSchedule: [UpdateRequest] = [] + var updatesToCancel: [UpdateRequest] = [] + + if let expiration { + guard context.currentTime < expiration.deadline else { + throw Failure.timeout(reason: expiration.reason) + } + + if let request = state.request { + guard request.targetTime != expiration.deadline else { + return nil + } + updatesToCancel.append(request) + } + + let request = UpdateRequest( + id: ExpirationComponentRequestID.next(), + creationTime: context.currentTime, + targetTime: expiration.deadline, + tag: expiration.reason.rawValue + ) + state.request = request + updatesToSchedule.append(request) + } else if let request = state.request { + state.request = nil + updatesToCancel.append(request) + } + + guard !updatesToSchedule.isEmpty || !updatesToCancel.isEmpty else { + return nil + } + return GestureOutputMetadata( + updatesToSchedule: updatesToSchedule, + updatesToCancel: updatesToCancel + ) + } +} + +// MARK: - ExpirationComponent + GestureComponent + +extension ExpirationComponent: GestureComponent { + package typealias Value = Upstream.Value.Value +} + +// MARK: - ExpirationRecord + +package struct ExpirationRecord: Expirable, NestedCustomStringConvertible, Sendable { + package var payload: ExpirablePayload + package var expiration: Expiration? + + package init( + payload: ExpirablePayload, + expiration: Expiration? + ) { + self.payload = payload + self.expiration = expiration + } +} + +// MARK: - ExpirablePayload + +package enum ExpirablePayload: NestedCustomStringConvertible, Sendable { + case empty(GestureOutputEmptyReason) + case value(Value) + + package func populateNestedDescription(_ nested: inout NestedDescription) { + switch self { + case let .empty(reason): + nested.append(reason, label: "reason") + case let .value(value): + nested.append(value, label: "value") + } + } +} + +// MARK: - GestureOutput + ExpirationRecord [TBA] + +extension GestureOutput { + package func mapToExpirationRecord( + expiration: Expiration? + ) -> GestureOutput> { + switch self { + case let .empty(reason, metadata): + .value( + ExpirationRecord( + payload: .empty(reason), + expiration: expiration + ), + metadata: metadata + ) + case let .value(value, metadata): + .value( + ExpirationRecord( + payload: .value(value), + expiration: expiration + ), + metadata: metadata + ) + case let .finalValue(value, metadata): + .finalValue( + ExpirationRecord( + payload: .value(value), + expiration: expiration + ), + metadata: metadata + ) + } + } +} + +// MARK: - ExpirationComponentRequestID [TBA] + +private enum ExpirationComponentRequestID { + private static let nextID = Atomic(UInt32.zero) + + static func next() -> UInt32 { + let (_, id) = nextID.add(1, ordering: .relaxed) + return id + } +} From 0745ca721644052ff0a0fce44272095074505ef3 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 5 May 2026 23:19:17 +0800 Subject: [PATCH 04/20] Update ExpirationComponent --- .../Components/ExpirationComponent.swift | 101 +++++++++--------- 1 file changed, 50 insertions(+), 51 deletions(-) diff --git a/Sources/OpenGestures/Component/Components/ExpirationComponent.swift b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift index ee1c28f..e8fce7a 100644 --- a/Sources/OpenGestures/Component/Components/ExpirationComponent.swift +++ b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift @@ -3,7 +3,7 @@ // OpenGestures // // Audited for 9126.1.5 -// Status: WIP +// Status: Complete import Synchronization @@ -23,7 +23,7 @@ package struct ExpirationComponent: Sendable where Upstream: GestureCo package var upstream: Upstream - package struct State: NestedCustomStringConvertible, Sendable { + package struct State: GestureComponentState, NestedCustomStringConvertible, Sendable { package var request: UpdateRequest? package init() { @@ -50,20 +50,16 @@ package struct ExpirationComponent: Sendable where Upstream: GestureCo } } -// MARK: - ExpirationComponent + CompositeGestureComponent +// MARK: - ExpirationComponent + GestureComponent -extension ExpirationComponent: CompositeGestureComponent {} +extension ExpirationComponent: GestureComponent { + package typealias Value = Upstream.Value.Value +} -// MARK: - ExpirationComponent + StatefulGestureComponent +extension ExpirationComponent: CompositeGestureComponent {} extension ExpirationComponent: StatefulGestureComponent {} -// MARK: - ExpirationComponent.State + GestureComponentState - -extension ExpirationComponent.State: GestureComponentState {} - -// MARK: - ExpirationComponent + ValueTransformingComponent - extension ExpirationComponent: ValueTransformingComponent { package mutating func transform( _ value: Upstream.Value, @@ -74,7 +70,6 @@ extension ExpirationComponent: ValueTransformingComponent { for: value.expiration, context: context ) - switch value.payload { case let .empty(reason): return .empty(reason, metadata: metadata) @@ -90,49 +85,64 @@ extension ExpirationComponent: ValueTransformingComponent { private mutating func metadata( for expiration: Expiration?, context: GestureComponentContext - ) throws -> GestureOutputMetadata? { - var updatesToSchedule: [UpdateRequest] = [] - var updatesToCancel: [UpdateRequest] = [] - + ) throws -> GestureOutputMetadata { + let updatesToSchedule: [UpdateRequest] + let updatesToCancel: [UpdateRequest] if let expiration { guard context.currentTime < expiration.deadline else { throw Failure.timeout(reason: expiration.reason) } - if let request = state.request { - guard request.targetTime != expiration.deadline else { - return nil - } - updatesToCancel.append(request) + if state.request?.targetTime == expiration.deadline { + updatesToSchedule = [] + updatesToCancel = [] + } else { + updatesToCancel = cancelStoredRequest() + updatesToSchedule = [scheduleRequest(for: expiration, context: context)] } - - let request = UpdateRequest( - id: ExpirationComponentRequestID.next(), - creationTime: context.currentTime, - targetTime: expiration.deadline, - tag: expiration.reason.rawValue - ) - state.request = request - updatesToSchedule.append(request) - } else if let request = state.request { - state.request = nil - updatesToCancel.append(request) - } - - guard !updatesToSchedule.isEmpty || !updatesToCancel.isEmpty else { - return nil + } else { + updatesToSchedule = [] + updatesToCancel = cancelStoredRequest() } return GestureOutputMetadata( updatesToSchedule: updatesToSchedule, updatesToCancel: updatesToCancel ) } + + private mutating func cancelStoredRequest() -> [UpdateRequest] { + guard let request = state.request else { + return [] + } + state.request = nil + return [request] + } + + private mutating func scheduleRequest( + for expiration: Expiration, + context: GestureComponentContext + ) -> UpdateRequest { + let request = UpdateRequest( + id: ExpirationComponentRequestID.next(), + creationTime: context.currentTime, + targetTime: expiration.deadline, + tag: expiration.reason.rawValue + ) + state.request = request + return request + } } -// MARK: - ExpirationComponent + GestureComponent +// MARK: - ExpirationComponentRequestID -extension ExpirationComponent: GestureComponent { - package typealias Value = Upstream.Value.Value +// FIXE: Should it in UpdateRequest namespace? +private enum ExpirationComponentRequestID { + private static let nextID = Atomic(UInt32.zero) + + static func next() -> UInt32 { + let (_, id) = nextID.add(1, ordering: .relaxed) + return id + } } // MARK: - ExpirationRecord @@ -200,14 +210,3 @@ extension GestureOutput { } } } - -// MARK: - ExpirationComponentRequestID [TBA] - -private enum ExpirationComponentRequestID { - private static let nextID = Atomic(UInt32.zero) - - static func next() -> UInt32 { - let (_, id) = nextID.add(1, ordering: .relaxed) - return id - } -} From cc2b89ec5accf8ff9d77db638561b71d656bff6e Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 5 May 2026 23:19:56 +0800 Subject: [PATCH 05/20] Fix missing context argument for ValueTransformingComponent.transform --- Sources/OpenGestures/Component/Gate/DiscreteGate.swift | 3 ++- Sources/OpenGestures/Component/Gate/MovementGate.swift | 3 ++- .../OpenGestures/Component/Track/ValueTracker.swift | 3 ++- .../Component/ValueTransformingComponent.swift | 10 ++++++---- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/OpenGestures/Component/Gate/DiscreteGate.swift b/Sources/OpenGestures/Component/Gate/DiscreteGate.swift index a2706c2..fb75847 100644 --- a/Sources/OpenGestures/Component/Gate/DiscreteGate.swift +++ b/Sources/OpenGestures/Component/Gate/DiscreteGate.swift @@ -34,7 +34,8 @@ extension DiscreteGate: DiscreteComponent {} extension DiscreteGate: ValueTransformingComponent { package mutating func transform( _ value: Value, - isFinal: Bool + isFinal: Bool, + context: GestureComponentContext ) throws -> GestureOutput { if isFinal { return .finalValue(value, metadata: nil) diff --git a/Sources/OpenGestures/Component/Gate/MovementGate.swift b/Sources/OpenGestures/Component/Gate/MovementGate.swift index d215056..1f70e89 100644 --- a/Sources/OpenGestures/Component/Gate/MovementGate.swift +++ b/Sources/OpenGestures/Component/Gate/MovementGate.swift @@ -51,7 +51,8 @@ extension MovementGate: CompositeGestureComponent {} extension MovementGate: ValueTransformingComponent { package mutating func transform( _ value: Upstream.Value, - isFinal: Bool + isFinal: Bool, + context: GestureComponentContext ) throws -> GestureOutput { let movement = value.locationTranslation.magnitude switch restriction { diff --git a/Sources/OpenGestures/Component/Track/ValueTracker.swift b/Sources/OpenGestures/Component/Track/ValueTracker.swift index 2ac9cfa..68d48e4 100644 --- a/Sources/OpenGestures/Component/Track/ValueTracker.swift +++ b/Sources/OpenGestures/Component/Track/ValueTracker.swift @@ -49,7 +49,8 @@ extension ValueTracker: StatefulGestureComponent {} extension ValueTracker: ValueTransformingComponent { package mutating func transform( _ value: Upstream.Value, - isFinal: Bool + isFinal: Bool, + context: GestureComponentContext ) throws -> GestureOutput { let current = valueReader(value) if state.initialValue == nil { diff --git a/Sources/OpenGestures/Component/ValueTransformingComponent.swift b/Sources/OpenGestures/Component/ValueTransformingComponent.swift index b01f5a6..5c478ca 100644 --- a/Sources/OpenGestures/Component/ValueTransformingComponent.swift +++ b/Sources/OpenGestures/Component/ValueTransformingComponent.swift @@ -10,7 +10,8 @@ package protocol ValueTransformingComponent: CompositeGestureComponent { mutating func transform( _ value: Upstream.Value, - isFinal: Bool + isFinal: Bool, + context: GestureComponentContext ) throws -> GestureOutput } @@ -23,9 +24,9 @@ extension ValueTransformingComponent { case let .empty(reason, metadata): return .empty(reason, metadata: metadata) case let .value(value, _): - return try transform(value, isFinal: false) + return try transform(value, isFinal: false, context: context) case let .finalValue(value, _): - return try transform(value, isFinal: true) + return try transform(value, isFinal: true, context: context) } } } @@ -33,7 +34,8 @@ extension ValueTransformingComponent { extension ValueTransformingComponent where Value == Upstream.Value { package mutating func transform( _ value: Upstream.Value, - isFinal: Bool + isFinal: Bool, + context: GestureComponentContext ) throws -> GestureOutput { if isFinal { return .finalValue(value, metadata: nil) From 7d0a12d137a1b26aa271b556943fab48936f0ab5 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 6 May 2026 00:48:22 +0800 Subject: [PATCH 06/20] Add DurationGate --- .../Components/ExpirationComponent.swift | 14 +-- .../Component/Gate/DurationGate.swift | 94 +++++++++++++++++++ 2 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 Sources/OpenGestures/Component/Gate/DurationGate.swift diff --git a/Sources/OpenGestures/Component/Components/ExpirationComponent.swift b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift index e8fce7a..a17446c 100644 --- a/Sources/OpenGestures/Component/Components/ExpirationComponent.swift +++ b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift @@ -176,23 +176,23 @@ package enum ExpirablePayload: NestedCustomStringConvertible, S } } -// MARK: - GestureOutput + ExpirationRecord [TBA] +// MARK: - GestureOutput + ExpirationRecord extension GestureOutput { - package func mapToExpirationRecord( - expiration: Expiration? + package func expired( + with expiration: Expiration? ) -> GestureOutput> { switch self { case let .empty(reason, metadata): - .value( - ExpirationRecord( + return .value( + ExpirationRecord( payload: .empty(reason), expiration: expiration ), metadata: metadata ) case let .value(value, metadata): - .value( + return .value( ExpirationRecord( payload: .value(value), expiration: expiration @@ -200,7 +200,7 @@ extension GestureOutput { metadata: metadata ) case let .finalValue(value, metadata): - .finalValue( + return .finalValue( ExpirationRecord( payload: .value(value), expiration: expiration diff --git a/Sources/OpenGestures/Component/Gate/DurationGate.swift b/Sources/OpenGestures/Component/Gate/DurationGate.swift new file mode 100644 index 0000000..c6844f2 --- /dev/null +++ b/Sources/OpenGestures/Component/Gate/DurationGate.swift @@ -0,0 +1,94 @@ +// +// DurationGate.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - DurationGate + +package struct DurationGate: Sendable where Upstream: GestureComponent { + package enum Failure: Error, Hashable, Sendable { + case minimumDurationNotReached + } + + package var upstream: Upstream + package let minimumDuration: Duration + package let maximumDuration: Duration + + package init( + upstream: Upstream, + minimumDuration: Duration, + maximumDuration: Duration + ) { + self.upstream = upstream + self.minimumDuration = minimumDuration + self.maximumDuration = maximumDuration + } +} + +// MARK: - DurationGate + GestureComponent + +extension DurationGate: GestureComponent { + package typealias Value = ExpirationRecord +} + +// MARK: - DurationGate + CompositeGestureComponent + +extension DurationGate: CompositeGestureComponent {} + +// MARK: - DurationGate + ValueTransformingComponent + +extension DurationGate: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + if context.durationSinceStart < minimumDuration { + guard !isFinal else { + throw Failure.minimumDurationNotReached + } + let output = GestureOutput.empty( + .filtered, + metadata: GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "min duration not reached") + ) + ) + return Self.makeExpirationOutput( + output, + from: context.startTime, + after: minimumDuration, + reason: "min duration expired" + ) + } else { + let output: GestureOutput + if isFinal { + output = .finalValue(value, metadata: nil) + } else { + output = .value(value, metadata: nil) + } + return Self.makeExpirationOutput( + output, + from: context.startTime, + after: maximumDuration, + reason: "max duration expired" + ) + } + } + + private static func makeExpirationOutput( + _ output: GestureOutput, + from startTime: Timestamp, + after duration: Duration, + reason: ExpirationReason + ) -> GestureOutput> { + let expiration: Expiration? + if .zero < duration, duration < .max { + expiration = Expiration(deadline: startTime + duration, reason: reason) + } else { + expiration = nil + } + return output.expired(with: expiration) + } +} From da737471d3345a680f32e7a20da7a0ced1b735b9 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 6 May 2026 01:55:18 +0800 Subject: [PATCH 07/20] Add SeparationDistanceGate --- .../Gate/SeparationDistanceGate.swift | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 Sources/OpenGestures/Component/Gate/SeparationDistanceGate.swift diff --git a/Sources/OpenGestures/Component/Gate/SeparationDistanceGate.swift b/Sources/OpenGestures/Component/Gate/SeparationDistanceGate.swift new file mode 100644 index 0000000..f381317 --- /dev/null +++ b/Sources/OpenGestures/Component/Gate/SeparationDistanceGate.swift @@ -0,0 +1,106 @@ +// +// SeparationDistanceGate.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +import OpenCoreGraphicsShims + +// MARK: - SeparationDistanceGate + +package struct SeparationDistanceGate: Sendable +where Upstream: GestureComponent, + Upstream.Value: Collection, + Upstream.Value.Element: LocationContaining +{ + package enum Failure: Error, Hashable, Sendable { + case exceedsAllowedDistance + } + + package var upstream: Upstream + package let distance: Double + + package init( + upstream: Upstream, + distance: Double + ) { + self.upstream = upstream + self.distance = distance + } +} + +// MARK: - SeparationDistanceGate + GestureComponent + +extension SeparationDistanceGate: GestureComponent { + package typealias Value = Upstream.Value +} + +// MARK: - SeparationDistanceGate + CompositeGestureComponent + +extension SeparationDistanceGate: CompositeGestureComponent {} + +// MARK: - SeparationDistanceGate + ValueTransformingComponent + +extension SeparationDistanceGate: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + if distance < .greatestFiniteMagnitude, + let separationDistance = value.separationDistance, + distance < separationDistance { + throw Failure.exceedsAllowedDistance + } + if isFinal { + return .finalValue(value, metadata: nil) + } else { + return .value(value, metadata: nil) + } + } +} + +// MARK: - Collection + Separation Distance + +private extension Collection where Element: LocationContaining { + var separationDistance: Double? { + guard count >= 2 else { + return nil + } + + let rect = map { $0.location }.boundingRect + let dx = Double(rect.minX - rect.maxX) + let dy = Double(rect.minY - rect.maxY) + return (dx * dx + dy * dy).squareRoot() + } +} + +// MARK: - CGPoint + Bounding Rect + +extension Collection where Element == CGPoint { + fileprivate var boundingRect: CGRect { + guard !isEmpty else { + return .null + } + let first = self[startIndex] + var minX = Double(first.x) + var minY = Double(first.y) + var maxX = minX + var maxY = minY + for point in self { + let x = Double(point.x) + let y = Double(point.y) + minX = Swift.min(minX, x) + minY = Swift.min(minY, y) + maxX = Swift.max(maxX, x) + maxY = Swift.max(maxY, y) + } + return CGRect( + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + ) + } +} From c7ea32d73e6d6d195bab9846a7beb8e93ecaa4df Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 6 May 2026 01:59:49 +0800 Subject: [PATCH 08/20] Add test case --- .../Components/ExpirationComponentTests.swift | 332 ++++++++++++++++++ .../Component/ExpirationTests.swift | 23 ++ .../Component/Gate/DurationGateTests.swift | 125 +++++++ .../{ => Gate}/MovementGateTests.swift | 0 .../Gate/SeparationDistanceGateTests.swift | 108 ++++++ .../{ => Track}/UpdateTracerTests.swift | 0 6 files changed, 588 insertions(+) create mode 100644 Tests/OpenGesturesTests/Component/Components/ExpirationComponentTests.swift create mode 100644 Tests/OpenGesturesTests/Component/ExpirationTests.swift create mode 100644 Tests/OpenGesturesTests/Component/Gate/DurationGateTests.swift rename Tests/OpenGesturesTests/Component/{ => Gate}/MovementGateTests.swift (100%) create mode 100644 Tests/OpenGesturesTests/Component/Gate/SeparationDistanceGateTests.swift rename Tests/OpenGesturesTests/Component/{ => Track}/UpdateTracerTests.swift (100%) diff --git a/Tests/OpenGesturesTests/Component/Components/ExpirationComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/ExpirationComponentTests.swift new file mode 100644 index 0000000..81f8382 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/ExpirationComponentTests.swift @@ -0,0 +1,332 @@ +// +// ExpirationComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - ExpirationComponentTests + +@Suite +struct ExpirationComponentTests { + @Test + func schedulesExpirationAndUnwrapsValuePayload() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + expirationRecord( + value: 7, + deadline: .seconds(5), + reason: "timeout" + ), + metadata: nil + ), + ]) + ) + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == 7) + + guard let metadata, let request = metadata.updatesToSchedule.first else { + Issue.record("Expected scheduled request metadata") + return + } + #expect(metadata.updatesToSchedule.count == 1) + #expect(metadata.updatesToCancel.isEmpty) + #expect(request.creationTime == Timestamp(value: .seconds(2))) + #expect(request.targetTime == Timestamp(value: .seconds(5))) + #expect(request.tag == "timeout") + #expect(component.state.request == request) + } + + @Test + func reschedulesWhenExpirationDeadlineChanges() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + expirationRecord( + value: 7, + deadline: .seconds(5), + reason: "first" + ), + metadata: nil + ), + .value( + expirationRecord( + value: 8, + deadline: .seconds(7), + reason: "second" + ), + metadata: nil + ), + ]) + ) + + _ = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + guard let firstRequest = component.state.request else { + Issue.record("Expected first request") + return + } + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(3)) + ) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == 8) + + guard let metadata, let scheduledRequest = metadata.updatesToSchedule.first else { + Issue.record("Expected reschedule metadata") + return + } + #expect(metadata.updatesToSchedule.count == 1) + #expect(metadata.updatesToCancel == [firstRequest]) + #expect(scheduledRequest.targetTime == Timestamp(value: .seconds(7))) + #expect(component.state.request == scheduledRequest) + } + + @Test + func unchangedExpirationDeadlineDoesNotReschedule() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + expirationRecord( + value: 7, + deadline: .seconds(5), + reason: "first" + ), + metadata: nil + ), + .value( + expirationRecord( + value: 8, + deadline: .seconds(5), + reason: "second" + ), + metadata: nil + ), + ]) + ) + + _ = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + let firstRequest = component.state.request + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(3)) + ) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == 8) + guard let metadata else { + Issue.record("Expected empty metadata") + return + } + #expect(metadata.updatesToSchedule.isEmpty) + #expect(metadata.updatesToCancel.isEmpty) + #expect(component.state.request == firstRequest) + } + + @Test + func nilExpirationWithoutStoredRequestReturnsEmptyMetadata() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + ExpirationRecord( + payload: .value(7), + expiration: nil + ), + metadata: nil + ), + ]) + ) + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == 7) + + guard let metadata else { + Issue.record("Expected empty metadata") + return + } + #expect(metadata.updatesToSchedule.isEmpty) + #expect(metadata.updatesToCancel.isEmpty) + #expect(component.state.request == nil) + } + + @Test + func cancelsRequestWhenExpirationClears() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + expirationRecord( + value: 7, + deadline: .seconds(5), + reason: "timeout" + ), + metadata: nil + ), + .value( + ExpirationRecord( + payload: .value(8), + expiration: nil + ), + metadata: nil + ), + ]) + ) + + _ = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + guard let firstRequest = component.state.request else { + Issue.record("Expected first request") + return + } + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(3)) + ) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == 8) + + guard let metadata else { + Issue.record("Expected cancel metadata") + return + } + #expect(metadata.updatesToSchedule.isEmpty) + #expect(metadata.updatesToCancel == [firstRequest]) + #expect(component.state.request == nil) + } + + @Test + func emptyPayloadPreservesReasonAndSchedulesExpiration() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + ExpirationRecord( + payload: .empty(.filtered), + expiration: Expiration( + deadline: Timestamp(value: .seconds(5)), + reason: "filtered timeout" + ) + ), + metadata: nil + ), + ]) + ) + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + + guard case let .empty(reason, metadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .filtered) + #expect(metadata?.updatesToSchedule.count == 1) + } + + @Test + func timeoutThrowsWhenCurrentTimeReachesDeadline() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + expirationRecord( + value: 7, + deadline: .seconds(5), + reason: "expired" + ), + metadata: nil + ), + ]) + ) + + do { + _ = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(5)) + ) + Issue.record("Expected timeout failure") + } catch ExpirationComponent.Failure.timeout(let reason) { + #expect(reason.description == "expired") + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} + +private func expirationRecord( + value: Int, + deadline: Duration, + reason: ExpirationReason +) -> ExpirationRecord { + ExpirationRecord( + payload: .value(value), + expiration: Expiration( + deadline: Timestamp(value: deadline), + reason: reason + ) + ) +} + +private func makeExpirationComponentContext( + currentTime: Duration +) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: currentTime), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct ExpirationRecordStubComponent: GestureComponent { + var outputs: [GestureOutput>] + + mutating func update( + context: GestureComponentContext + ) throws -> GestureOutput> { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/ExpirationTests.swift b/Tests/OpenGesturesTests/Component/ExpirationTests.swift new file mode 100644 index 0000000..9073779 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/ExpirationTests.swift @@ -0,0 +1,23 @@ +// +// ExpirationTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - ExpirationTests + +@Suite +struct ExpirationTests { + @Test + func expirablePayloadDescriptionUsesFrameworkLabels() { + let emptyDescription = ExpirablePayload.empty(.filtered).description + #expect(emptyDescription.contains("reason: filtered")) + #expect(!emptyDescription.contains("empty:")) + + let valueDescription = ExpirablePayload.value(7).description + #expect(valueDescription.contains("value: 7")) + } +} diff --git a/Tests/OpenGesturesTests/Component/Gate/DurationGateTests.swift b/Tests/OpenGesturesTests/Component/Gate/DurationGateTests.swift new file mode 100644 index 0000000..0377cf8 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Gate/DurationGateTests.swift @@ -0,0 +1,125 @@ +// +// DurationGateTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - DurationGateTests + +@Suite +struct DurationGateTests { + @Test + func filtersNonFinalValuesUntilMinimumDurationIsReached() throws { + var component = DurationGate( + upstream: IntStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + minimumDuration: .seconds(2), + maximumDuration: .seconds(10) + ) + + let output = try component.update( + context: makeDurationGateContext(currentTime: .seconds(1)) + ) + + guard case let .value(record, metadata) = output else { + Issue.record("Expected wrapped value output") + return + } + guard case let .empty(reason) = record.payload else { + Issue.record("Expected empty payload") + return + } + #expect(reason == .filtered) + #expect(metadata?.traceAnnotation?.value == "min duration not reached") + #expect(record.expiration?.deadline == Timestamp(value: .seconds(2))) + #expect(record.expiration?.reason.description == "min duration expired") + } + + @Test + func finalValueBeforeMinimumDurationThrows() throws { + var component = DurationGate( + upstream: IntStubComponent(outputs: [ + .finalValue(7, metadata: nil), + ]), + minimumDuration: .seconds(2), + maximumDuration: .seconds(10) + ) + + do { + _ = try component.update( + context: makeDurationGateContext(currentTime: .seconds(1)) + ) + Issue.record("Expected minimumDurationNotReached") + } catch DurationGate.Failure.minimumDurationNotReached { + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func valuesAfterMinimumDurationCarryMaximumExpiration() throws { + var component = DurationGate( + upstream: IntStubComponent(outputs: [ + .finalValue(9, metadata: nil), + ]), + minimumDuration: .seconds(2), + maximumDuration: .seconds(10) + ) + + let output = try component.update( + context: makeDurationGateContext( + startTime: .seconds(3), + currentTime: .seconds(6) + ) + ) + + guard case let .finalValue(record, metadata) = output else { + Issue.record("Expected wrapped final value") + return + } + guard case let .value(value) = record.payload else { + Issue.record("Expected value payload") + return + } + #expect(value == 9) + #expect(metadata == nil) + #expect(record.expiration?.deadline == Timestamp(value: .seconds(13))) + #expect(record.expiration?.reason.description == "max duration expired") + } +} + +private func makeDurationGateContext( + startTime: Duration = .zero, + currentTime: Duration +) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: startTime), + currentTime: Timestamp(value: currentTime), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct IntStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/MovementGateTests.swift b/Tests/OpenGesturesTests/Component/Gate/MovementGateTests.swift similarity index 100% rename from Tests/OpenGesturesTests/Component/MovementGateTests.swift rename to Tests/OpenGesturesTests/Component/Gate/MovementGateTests.swift diff --git a/Tests/OpenGesturesTests/Component/Gate/SeparationDistanceGateTests.swift b/Tests/OpenGesturesTests/Component/Gate/SeparationDistanceGateTests.swift new file mode 100644 index 0000000..cb00851 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Gate/SeparationDistanceGateTests.swift @@ -0,0 +1,108 @@ +// +// SeparationDistanceGateTests.swift +// OpenGesturesTests +// +// Generated + +import OpenCoreGraphicsShims +import OpenGestures +import Testing + +// MARK: - SeparationDistanceGateTests + +@Suite +struct SeparationDistanceGateTests { + @Test + func passesWhenBoundingDistanceIsWithinLimit() throws { + var component = SeparationDistanceGate( + upstream: LocationArrayStubComponent(outputs: [ + .value([ + CGPoint(x: 0, y: 0), + CGPoint(x: 3, y: 4), + ], metadata: nil), + ]), + distance: 5 + ) + + let output = try component.update(context: makeSeparationDistanceGateContext()) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == [CGPoint(x: 0, y: 0), CGPoint(x: 3, y: 4)]) + #expect(metadata == nil) + } + + @Test + func throwsWhenBoundingDistanceExceedsLimit() throws { + var component = SeparationDistanceGate( + upstream: LocationArrayStubComponent(outputs: [ + .value([ + CGPoint(x: -3, y: -4), + CGPoint(x: 3, y: 4), + ], metadata: nil), + ]), + distance: 9 + ) + + do { + _ = try component.update(context: makeSeparationDistanceGateContext()) + Issue.record("Expected exceedsAllowedDistance") + } catch SeparationDistanceGate.Failure.exceedsAllowedDistance { + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func greatestFiniteDistanceDisablesTheGate() throws { + var component = SeparationDistanceGate( + upstream: LocationArrayStubComponent(outputs: [ + .finalValue([ + CGPoint(x: -1_000, y: -1_000), + CGPoint(x: 1_000, y: 1_000), + ], metadata: nil), + ]), + distance: .greatestFiniteMagnitude + ) + + let output = try component.update(context: makeSeparationDistanceGateContext()) + + guard case let .finalValue(value, metadata) = output else { + Issue.record("Expected final value output") + return + } + #expect(value.count == 2) + #expect(metadata == nil) + } +} + +private func makeSeparationDistanceGateContext() -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct LocationArrayStubComponent: GestureComponent { + var outputs: [GestureOutput<[CGPoint]>] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput<[CGPoint]> { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/UpdateTracerTests.swift b/Tests/OpenGesturesTests/Component/Track/UpdateTracerTests.swift similarity index 100% rename from Tests/OpenGesturesTests/Component/UpdateTracerTests.swift rename to Tests/OpenGesturesTests/Component/Track/UpdateTracerTests.swift From 3d825f2c1d9f60694224bb1d4cf169050874dd01 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 6 May 2026 02:29:38 +0800 Subject: [PATCH 09/20] Add MapComponent --- .../Component/Components/MapComponent.swift | 36 ++++++++++ .../Components/MapComponentTests.swift | 66 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 Sources/OpenGestures/Component/Components/MapComponent.swift create mode 100644 Tests/OpenGesturesTests/Component/Components/MapComponentTests.swift diff --git a/Sources/OpenGestures/Component/Components/MapComponent.swift b/Sources/OpenGestures/Component/Components/MapComponent.swift new file mode 100644 index 0000000..b14935c --- /dev/null +++ b/Sources/OpenGestures/Component/Components/MapComponent.swift @@ -0,0 +1,36 @@ +// +// MapComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - MapComponent + +package struct MapComponent: Sendable +where Upstream: GestureComponent, Output: Sendable { + package var upstream: Upstream + package let map: @Sendable (GestureOutput) throws -> GestureOutput + + package init( + upstream: Upstream, + map: @escaping @Sendable (GestureOutput) throws -> GestureOutput + ) { + self.upstream = upstream + self.map = map + } +} + +// MARK: - MapComponent + GestureComponent + +extension MapComponent: GestureComponent { + package typealias Value = Output + + package mutating func update(context: GestureComponentContext) throws -> GestureOutput { + try map(upstream.tracingUpdate(context: context)) + } +} + +// MARK: - MapComponent + CompositeGestureComponent + +extension MapComponent: CompositeGestureComponent {} diff --git a/Tests/OpenGesturesTests/Component/Components/MapComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/MapComponentTests.swift new file mode 100644 index 0000000..e0f9cb8 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/MapComponentTests.swift @@ -0,0 +1,66 @@ +// +// MapComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - MapComponentTests + +@Suite +struct MapComponentTests { + @Test + func mapsWholeGestureOutput() throws { + var component = MapComponent( + upstream: MapStubComponent(outputs: [ + .value(3, metadata: nil), + ]), + map: { output in + guard case let .value(value, metadata) = output else { + return .empty(.filtered, metadata: nil) + } + return .value(String(value * 2), metadata: metadata) + } + ) + + let output = try component.update(context: mapComponentContext()) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected mapped value output") + return + } + #expect(value == "6") + #expect(metadata == nil) + } +} + +private func mapComponentContext() -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct MapStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} From 796a763a32c2c46b2c53db7ce02de087e5172d2c Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 6 May 2026 22:58:43 +0800 Subject: [PATCH 10/20] Add EventSource impl --- .../Component/Components/EventSource.swift | 121 +++++++++++ .../Components/EventSourceTests.swift | 196 ++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 Sources/OpenGestures/Component/Components/EventSource.swift create mode 100644 Tests/OpenGesturesTests/Component/Components/EventSourceTests.swift diff --git a/Sources/OpenGestures/Component/Components/EventSource.swift b/Sources/OpenGestures/Component/Components/EventSource.swift new file mode 100644 index 0000000..4392b1e --- /dev/null +++ b/Sources/OpenGestures/Component/Components/EventSource.swift @@ -0,0 +1,121 @@ +// +// EventSource.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - EventSource + +package struct EventSource: Sendable { + package enum Failure: Error, Hashable, Sendable { + case eventFailed + } + + package struct State: GestureComponentState, NestedCustomStringConvertible, Sendable { + package var trackedId: EventID? + + package init() { + trackedId = nil + } + + package init(trackedId: EventID?) { + self.trackedId = trackedId + } + } + + package var state: State + + package init(state: State = State()) { + self.state = state + } +} + +// MARK: - EventSource + GestureComponent + +extension EventSource: GestureComponent { + package typealias Value = E + + package mutating func update( + context: GestureComponentContext + ) throws -> GestureOutput { + guard context.updateSource == .event else { + return .empty(.timeUpdate, metadata: nil) + } + guard let store = matchingEventStore(context: context) else { + return makeEmptyOutput(traceAnnotation: "no event") + } + let event: E? + if let trackedId = state.trackedId { + event = trackedEvent(in: store, matching: trackedId) + } else { + event = store.bindNextUnboundEvent() + if let event { + state.trackedId = event.id + } + } + guard let event else { + if state.trackedId == nil { + return makeEmptyOutput( + traceAnnotation: "no unbound events" + ) + } else { + return makeEmptyOutput( + traceAnnotation: "source is already bound" + ) + } + } + return try makeOutputForEvent(event) + } + + private func trackedEvent( + in store: EventStore, + matching trackedId: EventID + ) -> E? { + return store.events.first { $0.id == trackedId } + } + + private func matchingEventStore( + context: GestureComponentContext + ) -> EventStore? { + guard context.eventStore.accepts(E.self) else { + return nil + } + return unsafeDowncast(context.eventStore, to: EventStore.self) + } + + private func makeOutputForEvent(_ event: E) throws -> GestureOutput { + switch event.phase { + case .began, .active: + return .value(event, metadata: nil) + case .ended: + return .finalValue(event, metadata: nil) + case .failed: + throw Failure.eventFailed + } + } + + private func makeEmptyOutput(traceAnnotation: String) -> GestureOutput { + .empty( + .noData, + metadata: GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: traceAnnotation) + ) + ) + } + + package func traits() -> GestureTraitCollection? { + nil + } + + package func capacity(for eventType: EventType.Type) -> Int { + if state.trackedId == nil, eventType == E.self { + return 1 + } + return 0 + } +} + +// MARK: - EventSource + StatefulGestureComponent + +extension EventSource: StatefulGestureComponent {} diff --git a/Tests/OpenGesturesTests/Component/Components/EventSourceTests.swift b/Tests/OpenGesturesTests/Component/Components/EventSourceTests.swift new file mode 100644 index 0000000..406c57b --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/EventSourceTests.swift @@ -0,0 +1,196 @@ +// +// EventSourceTests.swift +// OpenGesturesTests +// +// Generated + +import OpenCoreGraphicsShims +import OpenGestures +import Testing + +// MARK: - EventSourceTests + +@Suite +struct EventSourceTests { + @Test + func bindsNextUnboundBeganEvent() throws { + let store = EventStore() + store.append([ + touch(id: 1, phase: .began, time: .zero), + ]) + var source = EventSource() + + let output = try source.update(context: eventSourceContext(store: store)) + + guard case let .value(event, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(event.id == EventID(rawValue: 1)) + #expect(metadata == nil) + #expect(source.state.trackedId == EventID(rawValue: 1)) + #expect(store.boundEventIds == [EventID(rawValue: 1)]) + } + + @Test + func endedTrackedEventProducesFinalValueAndPreservesState() throws { + let store = EventStore( + events: [ + touch(id: 1, phase: .ended, time: .milliseconds(100)), + ], + boundEventIds: [EventID(rawValue: 1)] + ) + var source = EventSource( + state: EventSource.State(trackedId: EventID(rawValue: 1)) + ) + + let output = try source.update(context: eventSourceContext(store: store)) + + guard case let .finalValue(event, metadata) = output else { + Issue.record("Expected final value output") + return + } + #expect(event.phase == .ended) + #expect(metadata == nil) + #expect(source.state.trackedId == EventID(rawValue: 1)) + } + + @Test + func failedTrackedEventThrowsAndPreservesState() throws { + let store = EventStore( + events: [ + touch(id: 1, phase: .failed, time: .milliseconds(100)), + ], + boundEventIds: [EventID(rawValue: 1)] + ) + var source = EventSource( + state: EventSource.State(trackedId: EventID(rawValue: 1)) + ) + + do { + _ = try source.update(context: eventSourceContext(store: store)) + Issue.record("Expected eventFailed") + } catch EventSource.Failure.eventFailed { + #expect(source.state.trackedId == EventID(rawValue: 1)) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func schedulerUpdateProducesTimeUpdateWithoutBinding() throws { + let store = EventStore() + store.append([ + touch(id: 1, phase: .began, time: .zero), + ]) + var source = EventSource() + + let output = try source.update( + context: eventSourceContext( + store: store, + updateSource: .scheduler([1]) + ) + ) + + guard case let .empty(reason, metadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .timeUpdate) + #expect(metadata == nil) + #expect(source.state.trackedId == nil) + #expect(store.boundEventIds == []) + } + + @Test + func mismatchedEventStoreProducesNoEventTrace() throws { + var source = EventSource() + + let output = try source.update( + context: eventSourceContext(store: EventStore()) + ) + + expectEmpty(output, reason: .noData, traceAnnotation: "no event") + #expect(source.state.trackedId == nil) + } + + @Test + func noUnboundEventsProducesNoUnboundEventsTrace() throws { + let store = EventStore() + var source = EventSource() + + let output = try source.update(context: eventSourceContext(store: store)) + + expectEmpty(output, reason: .noData, traceAnnotation: "no unbound events") + #expect(source.state.trackedId == nil) + } + + @Test + func missingTrackedEventProducesAlreadyBoundTraceWithoutBindingAnotherEvent() throws { + let store = EventStore() + store.append([ + touch(id: 2, phase: .began, time: .zero), + ]) + var source = EventSource( + state: EventSource.State(trackedId: EventID(rawValue: 1)) + ) + + let output = try source.update(context: eventSourceContext(store: store)) + + expectEmpty(output, reason: .noData, traceAnnotation: "source is already bound") + #expect(source.state.trackedId == EventID(rawValue: 1)) + #expect(store.boundEventIds == []) + } + + @Test + func capacityIsOneForMatchingEventTypeOnlyWhenUnbound() { + let unboundSource = EventSource() + let boundSource = EventSource( + state: EventSource.State(trackedId: EventID(rawValue: 1)) + ) + + #expect(unboundSource.capacity(for: TouchEvent.self) == 1) + #expect(unboundSource.capacity(for: MouseEvent.self) == 0) + #expect(boundSource.capacity(for: TouchEvent.self) == 0) + } +} + +private func eventSourceContext( + store: AnyEventStore, + updateSource: GestureUpdateSource = .event +) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: updateSource, + eventStore: store + ) +} + +private func expectEmpty( + _ output: GestureOutput, + reason expectedReason: GestureOutputEmptyReason, + traceAnnotation expectedTraceAnnotation: String +) { + guard case let .empty(reason, metadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == expectedReason) + #expect(metadata?.updatesToSchedule.isEmpty == true) + #expect(metadata?.updatesToCancel.isEmpty == true) + #expect(metadata?.traceAnnotation?.value == expectedTraceAnnotation) +} + +private func touch( + id rawValue: Int, + phase: EventPhase, + time: Duration +) -> TouchEvent { + TouchEvent( + id: EventID(rawValue: rawValue), + phase: phase, + timestamp: Timestamp(value: time), + location: CGPoint(x: rawValue, y: rawValue) + ) +} From 1c755747c5f6ca861f099c89718dee40fcf3873d Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 7 May 2026 02:11:45 +0800 Subject: [PATCH 11/20] Add TimeoutComponent --- .../Components/TimeoutComponent.swift | 81 ++++++ Sources/OpenGestures/Core/GestureOutput.swift | 23 +- .../Components/TimeoutComponentTests.swift | 232 ++++++++++++++++++ 3 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 Sources/OpenGestures/Component/Components/TimeoutComponent.swift create mode 100644 Tests/OpenGesturesTests/Component/Components/TimeoutComponentTests.swift diff --git a/Sources/OpenGestures/Component/Components/TimeoutComponent.swift b/Sources/OpenGestures/Component/Components/TimeoutComponent.swift new file mode 100644 index 0000000..44845a1 --- /dev/null +++ b/Sources/OpenGestures/Component/Components/TimeoutComponent.swift @@ -0,0 +1,81 @@ +// +// TimeoutComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - TimeoutComponent + +package struct TimeoutComponent: Sendable where Upstream: GestureComponent { + package struct State: GestureComponentState, NestedCustomStringConvertible, Sendable { + package var fulfilled: Bool + + package init() { + fulfilled = false + } + + package init(fulfilled: Bool) { + self.fulfilled = fulfilled + } + } + + package var upstream: Upstream + package var state: State + package let timeout: Duration + package let tag: String + package let predicate: @Sendable (GestureOutput) -> Bool + + package init( + upstream: Upstream, + state: State = State(), + timeout: Duration, + tag: String, + predicate: @escaping @Sendable (GestureOutput) -> Bool + ) { + self.upstream = upstream + self.state = state + self.timeout = timeout + self.tag = tag + self.predicate = predicate + } +} + +// MARK: - TimeoutComponent + GestureComponent + +extension TimeoutComponent: GestureComponent { + package typealias Value = ExpirationRecord + + package mutating func update(context: GestureComponentContext) throws -> GestureOutput { + let output = try upstream.tracingUpdate(context: context) + guard output.emptyReason != .noData else { + return .empty(.noData, metadata: output.metadata) + } + return output.expired(with: expiration(for: output, context: context)) + } + + private mutating func expiration( + for output: GestureOutput, + context: GestureComponentContext + ) -> Expiration? { + guard timeout != .max, !state.fulfilled else { + return nil + } + + let deadline = context.startTime + timeout + if context.currentTime < deadline, predicate(output) { + state.fulfilled = true + return nil + } + return Expiration( + deadline: deadline, + reason: ExpirationReason(rawValue: tag) + ) + } +} + +// MARK: - TimeoutComponent + Component Protocols + +extension TimeoutComponent: CompositeGestureComponent {} + +extension TimeoutComponent: StatefulGestureComponent {} diff --git a/Sources/OpenGestures/Core/GestureOutput.swift b/Sources/OpenGestures/Core/GestureOutput.swift index 16c8d06..3ad8aea 100644 --- a/Sources/OpenGestures/Core/GestureOutput.swift +++ b/Sources/OpenGestures/Core/GestureOutput.swift @@ -29,6 +29,13 @@ extension GestureOutput { } } + package var emptyReason: GestureOutputEmptyReason? { + if case let .empty(reason, _) = self { + return reason + } + return nil + } + public var isFinal: Bool { switch self { case .finalValue: true @@ -52,17 +59,13 @@ extension GestureOutput { extension GestureOutput: NestedCustomStringConvertible { package func populateNestedDescription(_ nested: inout NestedDescription) { - let metadata: GestureOutputMetadata? switch self { - case let .empty(reason, m): - nested.append(reason, label: "emptyReason") - metadata = m - case let .value(v, m): - nested.append(v, label: "value") - metadata = m - case let .finalValue(v, m): - nested.append(v, label: "finalValue") - metadata = m + case .empty: + nested.append(emptyReason, label: "emptyReason") + case .value: + nested.append(value, label: "value") + case .finalValue: + nested.append(value, label: "finalValue") } if let metadata { nested.append(metadata, label: "metadata") diff --git a/Tests/OpenGesturesTests/Component/Components/TimeoutComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/TimeoutComponentTests.swift new file mode 100644 index 0000000..19ac32d --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/TimeoutComponentTests.swift @@ -0,0 +1,232 @@ +// +// TimeoutComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - TimeoutComponentTests + +@Suite +struct TimeoutComponentTests { + @Test + func attachesExpirationUntilPredicateIsFulfilled() throws { + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + timeout: .seconds(2), + tag: "timeout", + predicate: { _ in false } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .value(record, metadata) = output else { + Issue.record("Expected value output") + return + } + guard case let .value(value) = record.payload else { + Issue.record("Expected value payload") + return + } + #expect(value == 7) + #expect(metadata == nil) + #expect(record.expiration?.deadline == Timestamp(value: .seconds(2))) + #expect(record.expiration?.reason.description == "timeout") + #expect(component.state.fulfilled == false) + } + + @Test + func fulfilledPredicateClearsExpiration() throws { + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .finalValue(7, metadata: nil), + ]), + timeout: .seconds(2), + tag: "timeout", + predicate: { $0.isFinal } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .finalValue(record, metadata) = output else { + Issue.record("Expected final value output") + return + } + #expect(record.expiration == nil) + #expect(metadata == nil) + #expect(component.state.fulfilled == true) + } + + @Test + func noDataEmptyOutputBypassesExpirationAndPredicate() throws { + let probe = PredicateProbe(result: true) + let metadata = GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "no event") + ) + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .empty(.noData, metadata: metadata), + ]), + timeout: .seconds(2), + tag: "timeout", + predicate: { probe.call($0) } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .empty(reason, outputMetadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .noData) + #expect(outputMetadata?.traceAnnotation?.value == "no event") + #expect(probe.callCount == 0) + #expect(component.state.fulfilled == false) + } + + @Test + func predicateIsSkippedOnceCurrentTimeReachesDeadline() throws { + let probe = PredicateProbe(result: true) + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + timeout: .seconds(2), + tag: "timeout", + predicate: { probe.call($0) } + ) + + let output = try component.update( + context: timeoutComponentContext(currentTime: .seconds(2)) + ) + + guard case let .value(record, _) = output else { + Issue.record("Expected value output") + return + } + #expect(record.expiration?.deadline == Timestamp(value: .seconds(2))) + #expect(probe.callCount == 0) + #expect(component.state.fulfilled == false) + } + + @Test + func zeroTimeoutStillProducesImmediateExpiration() throws { + let probe = PredicateProbe(result: true) + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + timeout: .zero, + tag: "timeout", + predicate: { probe.call($0) } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .value(record, _) = output else { + Issue.record("Expected value output") + return + } + #expect(record.expiration?.deadline == Timestamp(value: .zero)) + #expect(record.expiration?.reason.description == "timeout") + #expect(probe.callCount == 0) + #expect(component.state.fulfilled == false) + } + + @Test + func maxTimeoutSuppressesExpirationAndPredicate() throws { + let probe = PredicateProbe(result: true) + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + timeout: .max, + tag: "timeout", + predicate: { probe.call($0) } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .value(record, _) = output else { + Issue.record("Expected value output") + return + } + #expect(record.expiration == nil) + #expect(probe.callCount == 0) + #expect(component.state.fulfilled == false) + } + + @Test + func fulfilledStateSuppressesExpirationAndPredicate() throws { + let probe = PredicateProbe(result: true) + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + state: .init(fulfilled: true), + timeout: .seconds(2), + tag: "timeout", + predicate: { probe.call($0) } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .value(record, _) = output else { + Issue.record("Expected value output") + return + } + #expect(record.expiration == nil) + #expect(probe.callCount == 0) + #expect(component.state.fulfilled == true) + } +} + +private func timeoutComponentContext( + startTime: Duration = .zero, + currentTime: Duration = .zero +) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: startTime), + currentTime: Timestamp(value: currentTime), + updateSource: .event, + eventStore: EventStore() + ) +} + +private final class PredicateProbe: @unchecked Sendable { + var callCount = 0 + var result: Bool + + init(result: Bool) { + self.result = result + } + + func call(_ output: GestureOutput) -> Bool { + callCount += 1 + return result + } +} + +private struct TimeoutStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} From 3db1f732162f79c5f56ea2b0f15686f56f067d18 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 7 May 2026 02:20:34 +0800 Subject: [PATCH 12/20] Add Interpolatable --- .../OpenGestures/Util/Interpolatable.swift | 43 ++++++ .../Util/InterpolatableTests.swift | 126 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 Sources/OpenGestures/Util/Interpolatable.swift create mode 100644 Tests/OpenGesturesTests/Util/InterpolatableTests.swift diff --git a/Sources/OpenGestures/Util/Interpolatable.swift b/Sources/OpenGestures/Util/Interpolatable.swift new file mode 100644 index 0000000..59966ed --- /dev/null +++ b/Sources/OpenGestures/Util/Interpolatable.swift @@ -0,0 +1,43 @@ +// +// Interpolatable.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - Interpolatable + +package protocol Interpolatable: Sendable { + func scaled(by rhs: Double) -> Self + + static func + (lhs: Self, rhs: Self) -> Self +} + +// MARK: - Interpolatable Helpers + +extension Interpolatable { + package static func mix( + _ lhs: Self, + _ rhs: Self, + by t: Double + ) -> Self { + rhs.scaled(by: 1 - t) + lhs.scaled(by: t) + } + + package mutating func mix( + with other: Self, + by t: Double + ) { + self = Self.mix(other, self, by: t) + } + + package func scaled(byInverseOf rhs: Double) -> Self { + scaled(by: 1 / rhs) + } +} + +// MARK: - Interpolatable Conformance + +extension CGPoint: Interpolatable {} + +extension CGVector: Interpolatable {} diff --git a/Tests/OpenGesturesTests/Util/InterpolatableTests.swift b/Tests/OpenGesturesTests/Util/InterpolatableTests.swift new file mode 100644 index 0000000..eb17728 --- /dev/null +++ b/Tests/OpenGesturesTests/Util/InterpolatableTests.swift @@ -0,0 +1,126 @@ +// +// InterpolatableTests.swift +// OpenGesturesTests + +import OpenCoreGraphicsShims +import OpenGestures +import Testing + +@Suite +struct InterpolatableTests { + @Test(arguments: [ + ( + CGPoint(x: 10, y: 20), + CGPoint(x: 2, y: 6), + 0.25, + CGPoint(x: 4, y: 9.5) + ), + ( + CGPoint(x: -4, y: 8), + CGPoint(x: 12, y: -16), + 0.5, + CGPoint(x: 4, y: -4) + ), + ( + CGPoint(x: 3, y: -9), + CGPoint(x: -5, y: 7), + 1, + CGPoint(x: 3, y: -9) + ), + ]) + func pointMixWeightsFirstOperandByT( + _ lhs: CGPoint, + _ rhs: CGPoint, + _ t: Double, + _ expected: CGPoint + ) { + #expect(CGPoint.mix(lhs, rhs, by: t) == expected) + + var value = rhs + value.mix(with: lhs, by: t) + #expect(value == expected) + } + + @Test(arguments: [ + ( + CGVector(dx: 10, dy: 20), + CGVector(dx: 2, dy: 6), + 0.25, + CGVector(dx: 4, dy: 9.5) + ), + ( + CGVector(dx: -4, dy: 8), + CGVector(dx: 12, dy: -16), + 0.5, + CGVector(dx: 4, dy: -4) + ), + ( + CGVector(dx: 3, dy: -9), + CGVector(dx: -5, dy: 7), + 1, + CGVector(dx: 3, dy: -9) + ), + ]) + func vectorMixWeightsFirstOperandByT( + _ lhs: CGVector, + _ rhs: CGVector, + _ t: Double, + _ expected: CGVector + ) { + #expect(CGVector.mix(lhs, rhs, by: t) == expected) + + var value = rhs + value.mix(with: lhs, by: t) + #expect(value == expected) + } + + @Test(arguments: [ + ( + CGPoint(x: 10, y: 20), + 4.0, + CGPoint(x: 2.5, y: 5) + ), + ( + CGPoint(x: -8, y: 12), + 2.0, + CGPoint(x: -4, y: 6) + ), + ( + CGPoint(x: 3, y: -9), + 1.0, + CGPoint(x: 3, y: -9) + ), + ]) + func pointScaledByInverseOfUsesReciprocalScale( + _ value: CGPoint, + _ scale: Double, + _ expected: CGPoint + ) { + #expect(value.scaled(byInverseOf: scale) == expected) + } + + @Test(arguments: [ + ( + CGVector(dx: 10, dy: 20), + 4.0, + CGVector(dx: 2.5, dy: 5) + ), + ( + CGVector(dx: -8, dy: 12), + 2.0, + CGVector(dx: -4, dy: 6) + ), + ( + CGVector(dx: 3, dy: -9), + 1.0, + CGVector(dx: 3, dy: -9) + ), + ]) + func vectorScaledByInverseOfUsesReciprocalScale( + _ value: CGVector, + _ scale: Double, + _ expected: CGVector + ) { + #expect(value.scaled(byInverseOf: scale) == expected) + } +} From 6d322d5d1cd79ad61388442b4b6cba4b98e51fb3 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 9 May 2026 17:04:53 +0800 Subject: [PATCH 13/20] Add VelocityComponent --- .../Components/VelocityComponent.swift | 136 +++++++++++++++++ .../Components/VelocityComponentTests.swift | 137 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 Sources/OpenGestures/Component/Components/VelocityComponent.swift create mode 100644 Tests/OpenGesturesTests/Component/Components/VelocityComponentTests.swift diff --git a/Sources/OpenGestures/Component/Components/VelocityComponent.swift b/Sources/OpenGestures/Component/Components/VelocityComponent.swift new file mode 100644 index 0000000..ef9fecc --- /dev/null +++ b/Sources/OpenGestures/Component/Components/VelocityComponent.swift @@ -0,0 +1,136 @@ +// +// VelocityComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - VelocityComponent + +package struct VelocityComponent: Sendable +where Upstream: GestureComponent, + Upstream.Value: VectorContaining, + Upstream.Value.VectorType: Interpolatable +{ + package struct State: GestureComponentState, NestedCustomStringConvertible { + package var previousValue: Upstream.Value.VectorType? + package var previousVelocity: Upstream.Value.VectorType? + package var previousTime: Timestamp? + + package init() { + previousValue = nil + previousVelocity = nil + previousTime = nil + } + + package init( + previousValue: Upstream.Value.VectorType?, + previousVelocity: Upstream.Value.VectorType?, + previousTime: Timestamp? + ) { + self.previousValue = previousValue + self.previousVelocity = previousVelocity + self.previousTime = previousTime + } + } + + package var upstream: Upstream + package var state: State + package let interpolationWeight: Double + + package init( + upstream: Upstream, + state: State = State(), + interpolationWeight: Double + ) { + self.upstream = upstream + self.state = state + self.interpolationWeight = interpolationWeight + } +} + +// MARK: - VelocityComponent + Component Protocols + +extension VelocityComponent: GestureComponent { + package typealias Value = (value: Upstream.Value, velocity: Upstream.Value.VectorType) +} + +extension VelocityComponent: CompositeGestureComponent {} + +extension VelocityComponent: StatefulGestureComponent {} + +extension VelocityComponent: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + let currentVector = value.vector + var velocity = makeRawVelocity( + currentVector: currentVector, + currentTime: context.currentTime + ) + if let previousVelocity = state.previousVelocity { + velocity.mix(with: previousVelocity, by: interpolationWeight) + } + + state.previousValue = currentVector + state.previousVelocity = velocity + state.previousTime = context.currentTime + + let result = (value: value, velocity: velocity) + if isFinal { + return .finalValue(result, metadata: nil) + } else { + return .value(result, metadata: nil) + } + } + + private func makeRawVelocity( + currentVector: Upstream.Value.VectorType, + currentTime: Timestamp + ) -> Upstream.Value.VectorType { + guard let previousValue = state.previousValue, + let previousTime = state.previousTime else { + return .zero + } + + let elapsed = previousTime.duration(to: currentTime) + guard elapsed >= .milliseconds(1) else { + return .zero + } + + let movement = currentVector - previousValue + return movement.scaled(byInverseOf: elapsed.asTimeInterval()) + } + + package mutating func transform2( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + let currentTime = context.currentTime + var velocity = Upstream.Value.VectorType.zero + if let previousValue = state.previousValue, + let previousTime = state.previousTime { + let elapsed = previousTime.duration(to: currentTime) + if elapsed >= .milliseconds(1) { + let movement = value.vector - previousValue + velocity = movement.scaled(byInverseOf: elapsed.asTimeInterval()) + } + } + if let previousVelocity = state.previousVelocity { + velocity.mix(with: previousVelocity, by: interpolationWeight) + } + state.previousValue = value.vector + state.previousVelocity = velocity + state.previousTime = currentTime + + let result = (value: value, velocity: velocity) + if isFinal { + return .finalValue(result, metadata: nil) + } else { + return .value(result, metadata: nil) + } + } +} diff --git a/Tests/OpenGesturesTests/Component/Components/VelocityComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/VelocityComponentTests.swift new file mode 100644 index 0000000..e7e2861 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/VelocityComponentTests.swift @@ -0,0 +1,137 @@ +// +// VelocityComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenCoreGraphicsShims +import OpenGestures +import Testing + +// MARK: - VelocityComponentTests + +@Suite +struct VelocityComponentTests { + @Test + func firstValueProducesZeroVelocityAndStoresState() throws { + var component = VelocityComponent( + upstream: PointStubComponent(outputs: [ + .value(CGPoint(x: 10, y: 0), metadata: nil), + ]), + interpolationWeight: 1 + ) + + let output = try component.update(context: velocityContext(currentTime: .seconds(1))) + + guard case let .value(result, metadata) = output else { + Issue.record("Expected velocity value") + return + } + #expect(result.value == CGPoint(x: 10, y: 0)) + #expect(result.velocity == CGPoint.zero) + #expect(component.state.previousValue == CGPoint(x: 10, y: 0)) + #expect(component.state.previousVelocity == CGPoint.zero) + #expect(component.state.previousTime == Timestamp(value: .seconds(1))) + #expect(metadata == nil) + } + + @Test + func computesVelocityFromElapsedTime() throws { + var component = VelocityComponent( + upstream: PointStubComponent(outputs: [ + .value(CGPoint(x: 10, y: 0), metadata: nil), + ]), + state: VelocityComponent.State( + previousValue: CGPoint.zero, + previousVelocity: nil, + previousTime: Timestamp(value: .seconds(1)) + ), + interpolationWeight: 1 + ) + + let output = try component.update(context: velocityContext(currentTime: .seconds(3))) + + guard case let .value(result, metadata) = output else { + Issue.record("Expected velocity value") + return + } + #expect(result.velocity == CGPoint(x: 5, y: 0)) + #expect(component.state.previousVelocity == CGPoint(x: 5, y: 0)) + #expect(metadata == nil) + } + + @Test + func interpolatesRawVelocityWithPreviousVelocity() throws { + var component = VelocityComponent( + upstream: PointStubComponent(outputs: [ + .value(CGPoint(x: 20, y: 0), metadata: nil), + ]), + state: VelocityComponent.State( + previousValue: CGPoint.zero, + previousVelocity: CGPoint(x: 2, y: 0), + previousTime: Timestamp(value: .zero) + ), + interpolationWeight: 0.25 + ) + + let output = try component.update(context: velocityContext(currentTime: .seconds(2))) + + guard case let .value(result, _) = output else { + Issue.record("Expected velocity value") + return + } + #expect(result.velocity == CGPoint(x: 8, y: 0)) + } + + @Test + func reusesPreviousVelocityForSubMillisecondElapsedTime() throws { + var component = VelocityComponent( + upstream: PointStubComponent(outputs: [ + .value(CGPoint(x: 20, y: 0), metadata: nil), + ]), + state: VelocityComponent.State( + previousValue: CGPoint.zero, + previousVelocity: CGPoint(x: 7, y: 0), + previousTime: Timestamp(value: .zero) + ), + interpolationWeight: 1 + ) + + let output = try component.update(context: velocityContext(currentTime: .microseconds(500))) + + guard case let .value(result, _) = output else { + Issue.record("Expected velocity value") + return + } + #expect(result.velocity == CGPoint(x: 7, y: 0)) + } +} + +private func velocityContext(currentTime: Duration) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: currentTime), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct PointStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} From 3b7ad2c8a01663762597a8ffc0e038b51f05d6ff Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 9 May 2026 17:29:17 +0800 Subject: [PATCH 14/20] Add ThresholdComponent --- .../Components/ThresholdComponent.swift | 102 +++++++++++++ .../Components/ThresholdComponentTests.swift | 138 ++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 Sources/OpenGestures/Component/Components/ThresholdComponent.swift create mode 100644 Tests/OpenGesturesTests/Component/Components/ThresholdComponentTests.swift diff --git a/Sources/OpenGestures/Component/Components/ThresholdComponent.swift b/Sources/OpenGestures/Component/Components/ThresholdComponent.swift new file mode 100644 index 0000000..f9a6eea --- /dev/null +++ b/Sources/OpenGestures/Component/Components/ThresholdComponent.swift @@ -0,0 +1,102 @@ +// +// ThresholdComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - ThresholdComponent + +package struct ThresholdComponent: Sendable +where Upstream: GestureComponent, + Upstream.Value: ThresholdAdjustable, + Upstream.Value.VectorType: Sendable +{ + package struct State: GestureComponentState, NestedCustomStringConvertible { + package var initialValue: Upstream.Value? + + package var adjustmentDelta: Upstream.Value.VectorType? + + package init() { + initialValue = nil + adjustmentDelta = nil + } + + package init( + initialValue: Upstream.Value?, + adjustmentDelta: Upstream.Value.VectorType? + ) { + self.initialValue = initialValue + self.adjustmentDelta = adjustmentDelta + } + } + + package enum Failure: Error, Hashable, Sendable { + case notEnoughMovement + } + + package var upstream: Upstream + + package var state: State + + package let threshold: @Sendable (Upstream.Value, Upstream.Value) -> Upstream.Value.Threshold + + package init( + upstream: Upstream, + state: State = State(), + threshold: @escaping @Sendable (Upstream.Value, Upstream.Value) -> Upstream.Value.Threshold + ) { + self.upstream = upstream + self.state = state + self.threshold = threshold + } +} + +// MARK: - ThresholdComponent + Component Protocols + +extension ThresholdComponent: GestureComponent { + package typealias Value = Upstream.Value +} + +extension ThresholdComponent: CompositeGestureComponent {} + +extension ThresholdComponent: StatefulGestureComponent {} + +extension ThresholdComponent: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + if state.initialValue == nil { + state.initialValue = value + } + let initialValue = state.initialValue! + guard let adjustmentDelta = state.adjustmentDelta else { + guard !isFinal else { + throw Failure.notEnoughMovement + } + var adjustedValue = value + guard let adjustmentDelta = adjustedValue.consume( + threshold(value, initialValue), + from: value.vector - initialValue.vector + ) else { + return .empty( + .filtered, + metadata: GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "not enough movement") + ) + ) + } + state.adjustmentDelta = adjustmentDelta + return .value(adjustedValue, metadata: nil) + } + var adjustedValue = value + adjustedValue.vector -= adjustmentDelta + if isFinal { + return .finalValue(adjustedValue, metadata: nil) + } else { + return .value(adjustedValue, metadata: nil) + } + } +} diff --git a/Tests/OpenGesturesTests/Component/Components/ThresholdComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/ThresholdComponentTests.swift new file mode 100644 index 0000000..bf6a425 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/ThresholdComponentTests.swift @@ -0,0 +1,138 @@ +// +// ThresholdComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenCoreGraphicsShims +import OpenGestures +import Testing + +// MARK: - ThresholdComponentTests + +@Suite +struct ThresholdComponentTests { + @Test + func filtersUntilMovementReachesThreshold() throws { + var component = ThresholdComponent( + upstream: ThresholdPointStubComponent(outputs: [ + .value(CGPoint.zero, metadata: nil), + .value(CGPoint(x: 3, y: 4), metadata: nil), + ]), + threshold: { _, _ in 5 } + ) + + let filteredOutput = try component.update(context: thresholdContext()) + let valueOutput = try component.update(context: thresholdContext()) + + guard case let .empty(reason, filteredMetadata) = filteredOutput else { + Issue.record("Expected filtered output") + return + } + #expect(reason == .filtered) + #expect(filteredMetadata?.traceAnnotation?.value == "not enough movement") + #expect(component.state.initialValue == CGPoint.zero) + + guard case let .value(value, valueMetadata) = valueOutput else { + Issue.record("Expected threshold-adjusted value") + return + } + #expect(value == CGPoint.zero) + #expect(component.state.adjustmentDelta == CGPoint(x: 3, y: 4)) + #expect(valueMetadata == nil) + } + + @Test + func existingAdjustmentDeltaOffsetsValuesAndPreservesFinal() throws { + var component = ThresholdComponent( + upstream: ThresholdPointStubComponent(outputs: [ + .finalValue(CGPoint(x: 6, y: 8), metadata: nil), + ]), + state: ThresholdComponent.State( + initialValue: CGPoint.zero, + adjustmentDelta: CGPoint(x: 3, y: 4) + ), + threshold: { _, _ in 5 } + ) + + let output = try component.update(context: thresholdContext()) + + guard case let .finalValue(value, metadata) = output else { + Issue.record("Expected final adjusted value") + return + } + #expect(value == CGPoint(x: 3, y: 4)) + #expect(metadata == nil) + } + + @Test + func thresholdClosureReceivesCurrentValueBeforeInitialValue() throws { + var component = ThresholdComponent( + upstream: ThresholdPointStubComponent(outputs: [ + .value(CGPoint.zero, metadata: nil), + .value(CGPoint(x: 6, y: 8), metadata: nil), + ]), + threshold: { currentValue, initialValue in + currentValue == CGPoint(x: 6, y: 8) && initialValue == .zero ? 5 : 20 + } + ) + + _ = try component.update(context: thresholdContext()) + let output = try component.update(context: thresholdContext()) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected threshold-adjusted value") + return + } + #expect(value == CGPoint(x: 3, y: 4)) + #expect(metadata == nil) + } + + @Test + func finalBeforeThresholdThrows() throws { + typealias Component = ThresholdComponent + var component = Component( + upstream: ThresholdPointStubComponent(outputs: [ + .finalValue(CGPoint.zero, metadata: nil), + ]), + threshold: { _, _ in 5 } + ) + + do { + _ = try component.update(context: thresholdContext()) + Issue.record("Expected notEnoughMovement") + } catch Component.Failure.notEnoughMovement { + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} + +private func thresholdContext() -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct ThresholdPointStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} From bf126626a95128e37016f48fca93c90d0e474b92 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 10 May 2026 01:00:27 +0800 Subject: [PATCH 15/20] Update GestureOutput helper --- .../Component/Components/ExpirationComponent.swift | 10 +++++----- .../Component/Components/ThresholdComponent.swift | 8 ++------ .../Component/Components/VelocityComponent.swift | 12 ++---------- .../OpenGestures/Component/Gate/DurationGate.swift | 10 ++++------ .../OpenGestures/Component/Gate/MovementGate.swift | 6 +----- .../Component/Gate/SeparationDistanceGate.swift | 6 +----- .../OpenGestures/Component/Track/ValueTracker.swift | 6 +----- .../Component/ValueTransformingComponent.swift | 6 +----- Sources/OpenGestures/Core/GestureOutput.swift | 12 ++++++++++++ 9 files changed, 29 insertions(+), 47 deletions(-) diff --git a/Sources/OpenGestures/Component/Components/ExpirationComponent.swift b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift index a17446c..99c4f53 100644 --- a/Sources/OpenGestures/Component/Components/ExpirationComponent.swift +++ b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift @@ -74,11 +74,11 @@ extension ExpirationComponent: ValueTransformingComponent { case let .empty(reason): return .empty(reason, metadata: metadata) case let .value(payload): - if isFinal { - return .finalValue(payload, metadata: metadata) - } else { - return .value(payload, metadata: metadata) - } + return .value( + payload, + isFinal: isFinal, + metadata: metadata + ) } } diff --git a/Sources/OpenGestures/Component/Components/ThresholdComponent.swift b/Sources/OpenGestures/Component/Components/ThresholdComponent.swift index f9a6eea..61d0712 100644 --- a/Sources/OpenGestures/Component/Components/ThresholdComponent.swift +++ b/Sources/OpenGestures/Component/Components/ThresholdComponent.swift @@ -89,14 +89,10 @@ extension ThresholdComponent: ValueTransformingComponent { ) } state.adjustmentDelta = adjustmentDelta - return .value(adjustedValue, metadata: nil) + return .value(adjustedValue, isFinal: false) } var adjustedValue = value adjustedValue.vector -= adjustmentDelta - if isFinal { - return .finalValue(adjustedValue, metadata: nil) - } else { - return .value(adjustedValue, metadata: nil) - } + return .value(adjustedValue, isFinal: isFinal) } } diff --git a/Sources/OpenGestures/Component/Components/VelocityComponent.swift b/Sources/OpenGestures/Component/Components/VelocityComponent.swift index ef9fecc..57030b3 100644 --- a/Sources/OpenGestures/Component/Components/VelocityComponent.swift +++ b/Sources/OpenGestures/Component/Components/VelocityComponent.swift @@ -79,11 +79,7 @@ extension VelocityComponent: ValueTransformingComponent { state.previousTime = context.currentTime let result = (value: value, velocity: velocity) - if isFinal { - return .finalValue(result, metadata: nil) - } else { - return .value(result, metadata: nil) - } + return .value(result, isFinal: isFinal) } private func makeRawVelocity( @@ -127,10 +123,6 @@ extension VelocityComponent: ValueTransformingComponent { state.previousTime = currentTime let result = (value: value, velocity: velocity) - if isFinal { - return .finalValue(result, metadata: nil) - } else { - return .value(result, metadata: nil) - } + return .value(result, isFinal: isFinal) } } diff --git a/Sources/OpenGestures/Component/Gate/DurationGate.swift b/Sources/OpenGestures/Component/Gate/DurationGate.swift index c6844f2..542af7f 100644 --- a/Sources/OpenGestures/Component/Gate/DurationGate.swift +++ b/Sources/OpenGestures/Component/Gate/DurationGate.swift @@ -62,12 +62,10 @@ extension DurationGate: ValueTransformingComponent { reason: "min duration expired" ) } else { - let output: GestureOutput - if isFinal { - output = .finalValue(value, metadata: nil) - } else { - output = .value(value, metadata: nil) - } + let output = GestureOutput.value( + value, + isFinal: isFinal + ) return Self.makeExpirationOutput( output, from: context.startTime, diff --git a/Sources/OpenGestures/Component/Gate/MovementGate.swift b/Sources/OpenGestures/Component/Gate/MovementGate.swift index 1f70e89..cc927b4 100644 --- a/Sources/OpenGestures/Component/Gate/MovementGate.swift +++ b/Sources/OpenGestures/Component/Gate/MovementGate.swift @@ -70,10 +70,6 @@ extension MovementGate: ValueTransformingComponent { throw Failure.tooMuchMovement } } - if isFinal { - return .finalValue(value, metadata: nil) - } else { - return .value(value, metadata: nil) - } + return .value(value, isFinal: isFinal) } } diff --git a/Sources/OpenGestures/Component/Gate/SeparationDistanceGate.swift b/Sources/OpenGestures/Component/Gate/SeparationDistanceGate.swift index f381317..b01272b 100644 --- a/Sources/OpenGestures/Component/Gate/SeparationDistanceGate.swift +++ b/Sources/OpenGestures/Component/Gate/SeparationDistanceGate.swift @@ -53,11 +53,7 @@ extension SeparationDistanceGate: ValueTransformingComponent { distance < separationDistance { throw Failure.exceedsAllowedDistance } - if isFinal { - return .finalValue(value, metadata: nil) - } else { - return .value(value, metadata: nil) - } + return .value(value, isFinal: isFinal) } } diff --git a/Sources/OpenGestures/Component/Track/ValueTracker.swift b/Sources/OpenGestures/Component/Track/ValueTracker.swift index 68d48e4..6c87cdf 100644 --- a/Sources/OpenGestures/Component/Track/ValueTracker.swift +++ b/Sources/OpenGestures/Component/Track/ValueTracker.swift @@ -63,10 +63,6 @@ extension ValueTracker: ValueTransformingComponent { initial: state.initialValue! ) state.previousValue = current - if isFinal { - return .finalValue(trackedValue, metadata: nil) - } else { - return .value(trackedValue, metadata: nil) - } + return .value(trackedValue, isFinal: isFinal) } } diff --git a/Sources/OpenGestures/Component/ValueTransformingComponent.swift b/Sources/OpenGestures/Component/ValueTransformingComponent.swift index 5c478ca..6c46972 100644 --- a/Sources/OpenGestures/Component/ValueTransformingComponent.swift +++ b/Sources/OpenGestures/Component/ValueTransformingComponent.swift @@ -37,10 +37,6 @@ extension ValueTransformingComponent where Value == Upstream.Value { isFinal: Bool, context: GestureComponentContext ) throws -> GestureOutput { - if isFinal { - return .finalValue(value, metadata: nil) - } else { - return .value(value, metadata: nil) - } + return .value(value, isFinal: isFinal) } } diff --git a/Sources/OpenGestures/Core/GestureOutput.swift b/Sources/OpenGestures/Core/GestureOutput.swift index 3ad8aea..d96d4b3 100644 --- a/Sources/OpenGestures/Core/GestureOutput.swift +++ b/Sources/OpenGestures/Core/GestureOutput.swift @@ -53,6 +53,18 @@ extension GestureOutput { metadata } } + + package static func value( + _ value: Value, + isFinal: Bool, + metadata: GestureOutputMetadata? = nil + ) -> Self { + if isFinal { + return .finalValue(value, metadata: metadata) + } else { + return .value(value, metadata: metadata) + } + } } // MARK: - GestureOutput + NestedCustomStringConvertible From 2ee3bd89551dabb8a883d889c818c2b40ba3d702 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 10 May 2026 01:09:20 +0800 Subject: [PATCH 16/20] Add ReduceComponent --- .../Components/ReduceComponent.swift | 58 +++++++++ .../Components/ReduceComponentTests.swift | 119 ++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 Sources/OpenGestures/Component/Components/ReduceComponent.swift create mode 100644 Tests/OpenGesturesTests/Component/Components/ReduceComponentTests.swift diff --git a/Sources/OpenGestures/Component/Components/ReduceComponent.swift b/Sources/OpenGestures/Component/Components/ReduceComponent.swift new file mode 100644 index 0000000..0bc5151 --- /dev/null +++ b/Sources/OpenGestures/Component/Components/ReduceComponent.swift @@ -0,0 +1,58 @@ +// +// ReduceComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - ReduceComponent + +package struct ReduceComponent: Sendable +where Upstream: GestureComponent, Output: Sendable { + package struct State: GestureComponentState, NestedCustomStringConvertible { + package var accumulator: Output? + + package init() { + accumulator = nil + } + } + + package var upstream: Upstream + package var state: State + package let initial: Output + package let reduce: @Sendable (Output, Upstream.Value) throws -> Output + + package init( + upstream: Upstream, + state: State = State(), + initial: Output, + reduce: @escaping @Sendable (Output, Upstream.Value) throws -> Output + ) { + self.upstream = upstream + self.state = state + self.initial = initial + self.reduce = reduce + } +} + +// MARK: - ReduceComponent + Component Protocols + +extension ReduceComponent: GestureComponent { + package typealias Value = Output +} + +extension ReduceComponent: CompositeGestureComponent {} + +extension ReduceComponent: StatefulGestureComponent {} + +extension ReduceComponent: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + let previous = state.accumulator ?? initial + state.accumulator = try reduce(previous, value) + return .value(state.accumulator!, isFinal: isFinal) + } +} diff --git a/Tests/OpenGesturesTests/Component/Components/ReduceComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/ReduceComponentTests.swift new file mode 100644 index 0000000..b3b5bd4 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/ReduceComponentTests.swift @@ -0,0 +1,119 @@ +// +// ReduceComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - ReduceComponentTests + +@Suite +struct ReduceComponentTests { + @Test + func accumulatesValuesAndStoresState() throws { + var component = ReduceComponent( + upstream: ReduceStubComponent(outputs: [ + .value(2, metadata: nil), + .finalValue(3, metadata: nil), + ]), + initial: 10, + reduce: { accumulator, value in accumulator + value } + ) + + let firstOutput = try component.update(context: reduceContext()) + let finalOutput = try component.update(context: reduceContext()) + + guard case let .value(firstValue, firstMetadata) = firstOutput else { + Issue.record("Expected first reduced value") + return + } + #expect(firstValue == 12) + #expect(firstMetadata == nil) + #expect(component.state.accumulator == 15) + + guard case let .finalValue(finalValue, finalMetadata) = finalOutput else { + Issue.record("Expected final reduced value") + return + } + #expect(finalValue == 15) + #expect(finalMetadata == nil) + #expect(component.state.accumulator == 15) + } + + @Test + func emptyOutputPassesThroughWithoutReducing() throws { + let metadata = GestureOutputMetadata(traceAnnotation: UpdateTraceAnnotation(value: "empty")) + var component = ReduceComponent( + upstream: ReduceStubComponent(outputs: [ + .empty(.filtered, metadata: metadata), + ]), + initial: 10, + reduce: { _, _ in + throw ReduceTestError.unexpectedReduce + } + ) + + let output = try component.update(context: reduceContext()) + + guard case let .empty(reason, outputMetadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .filtered) + #expect(outputMetadata?.traceAnnotation?.value == "empty") + #expect(component.state.accumulator == nil) + } + + @Test + func throwingReduceDoesNotStoreAccumulator() throws { + var component = ReduceComponent( + upstream: ReduceStubComponent(outputs: [ + .value(2, metadata: nil), + ]), + initial: 10, + reduce: { _, _ in + throw ReduceTestError.unexpectedReduce + } + ) + + #expect(throws: ReduceTestError.unexpectedReduce) { + try component.update(context: reduceContext()) + } + #expect(component.state.accumulator == nil) + } +} + +private enum ReduceTestError: Error { + case unexpectedReduce +} + +private func reduceContext() -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct ReduceStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} From 85f7c9b5765c56af2cf0bffc3ba1319f7d33dc03 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 10 May 2026 02:18:00 +0800 Subject: [PATCH 17/20] Update GestureOutput.metadata setter --- .../ValueTransformingComponent.swift | 12 ++- Sources/OpenGestures/Core/GestureOutput.swift | 51 +++++++++-- .../Core/GestureOutputTests.swift | 88 +++++++++++++++++++ 3 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 Tests/OpenGesturesTests/Core/GestureOutputTests.swift diff --git a/Sources/OpenGestures/Component/ValueTransformingComponent.swift b/Sources/OpenGestures/Component/ValueTransformingComponent.swift index 6c46972..b0b515b 100644 --- a/Sources/OpenGestures/Component/ValueTransformingComponent.swift +++ b/Sources/OpenGestures/Component/ValueTransformingComponent.swift @@ -23,10 +23,14 @@ extension ValueTransformingComponent { switch output { case let .empty(reason, metadata): return .empty(reason, metadata: metadata) - case let .value(value, _): - return try transform(value, isFinal: false, context: context) - case let .finalValue(value, _): - return try transform(value, isFinal: true, context: context) + case let .value(value, metadata): + var output = try transform(value, isFinal: false, context: context) + output.metadata = metadata + return output + case let .finalValue(value, metadata): + var output = try transform(value, isFinal: false, context: context) + output.metadata = metadata + return output } } } diff --git a/Sources/OpenGestures/Core/GestureOutput.swift b/Sources/OpenGestures/Core/GestureOutput.swift index d96d4b3..9b60c35 100644 --- a/Sources/OpenGestures/Core/GestureOutput.swift +++ b/Sources/OpenGestures/Core/GestureOutput.swift @@ -44,13 +44,25 @@ extension GestureOutput { } package var metadata: GestureOutputMetadata? { - switch self { - case let .empty(_, metadata): - metadata - case let .value(_, metadata): - metadata - case let .finalValue(_, metadata): - metadata + get { + switch self { + case let .empty(_, metadata): + metadata + case let .value(_, metadata): + metadata + case let .finalValue(_, metadata): + metadata + } + } + set { + switch self { + case let .empty(reason, _): + self = .empty(reason, metadata: .combineUpdateRequests(metadata, newValue)) + case let .value(value, _): + self = .value(value, metadata: .combineUpdateRequests(metadata, newValue)) + case let .finalValue(value, _): + self = .finalValue(value, metadata: .combineUpdateRequests(metadata, newValue)) + } } } @@ -109,6 +121,31 @@ public struct GestureOutputMetadata: Sendable { self.updatesToCancel = updatesToCancel self.traceAnnotation = traceAnnotation } + + package static func combineUpdateRequests( + _ first: GestureOutputMetadata?, + _ second: GestureOutputMetadata? + ) -> GestureOutputMetadata? { + switch (first, second) { + case (nil, nil): + return nil + case let (metadata?, nil): + return GestureOutputMetadata( + updatesToSchedule: metadata.updatesToSchedule, + updatesToCancel: metadata.updatesToCancel + ) + case let (nil, metadata?): + return GestureOutputMetadata( + updatesToSchedule: metadata.updatesToSchedule, + updatesToCancel: metadata.updatesToCancel + ) + case let (first?, second?): + return GestureOutputMetadata( + updatesToSchedule: first.updatesToSchedule + second.updatesToSchedule, + updatesToCancel: first.updatesToCancel + second.updatesToCancel + ) + } + } } // MARK: - GestureOutputMetadata + NestedCustomStringConvertible diff --git a/Tests/OpenGesturesTests/Core/GestureOutputTests.swift b/Tests/OpenGesturesTests/Core/GestureOutputTests.swift new file mode 100644 index 0000000..e49d487 --- /dev/null +++ b/Tests/OpenGesturesTests/Core/GestureOutputTests.swift @@ -0,0 +1,88 @@ +// +// GestureOutputTests.swift +// OpenGesturesTests + +import OpenGestures +import Testing + +// MARK: - GestureOutputTests + +@Suite +struct GestureOutputTests { + + // MARK: - metadata setter + + @Test + func metadataSetterCombinesMetadataAndPreservesValueCase() { + let updateToSchedule = UpdateRequest( + id: 1, + creationTime: Timestamp(value: .seconds(1)), + targetTime: Timestamp(value: .seconds(2)), + tag: "schedule" + ) + let updateToCancel = UpdateRequest( + id: 2, + creationTime: Timestamp(value: .seconds(3)), + targetTime: Timestamp(value: .seconds(4)), + tag: "cancel" + ) + let output: GestureOutput = .value( + 7, + metadata: GestureOutputMetadata( + updatesToSchedule: [updateToSchedule], + traceAnnotation: UpdateTraceAnnotation(value: "existing") + ) + ) + + var replaced = output + replaced.metadata = GestureOutputMetadata( + updatesToCancel: [updateToCancel], + traceAnnotation: UpdateTraceAnnotation(value: "replacement") + ) + + guard case let .value(value, metadata) = replaced else { + Issue.record("Expected value output") + return + } + #expect(value == 7) + #expect(metadata?.updatesToSchedule == [updateToSchedule]) + #expect(metadata?.updatesToCancel == [updateToCancel]) + #expect(metadata?.traceAnnotation == nil) + } + + @Test + func metadataSetterPreservesEmptyAndFinalCases() { + var emptyOutput: GestureOutput = .empty(.filtered, metadata: nil) + var finalOutput: GestureOutput = .finalValue(9, metadata: nil) + + let replacement = GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "replacement") + ) + emptyOutput.metadata = replacement + finalOutput.metadata = replacement + + guard case let .empty(reason, emptyMetadata) = emptyOutput else { + Issue.record("Expected empty output") + return + } + guard case let .finalValue(value, finalMetadata) = finalOutput else { + Issue.record("Expected final value output") + return + } + #expect(reason == .filtered) + #expect(emptyMetadata != nil) + #expect(emptyMetadata?.traceAnnotation == nil) + #expect(value == 9) + #expect(finalMetadata != nil) + #expect(finalMetadata?.traceAnnotation == nil) + } + + @Test + func metadataSetterKeepsNilWhenBothSidesAreNil() { + var output: GestureOutput = .value(3, metadata: nil) + + output.metadata = nil + + #expect(output.metadata == nil) + } +} From 6f856d61dda6d3ba9df227909f4d10574f6b39a1 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 10 May 2026 01:14:36 +0800 Subject: [PATCH 18/20] Add RepeatComponent --- .../Components/ExpirationComponent.swift | 14 + .../Components/RepeatComponent.swift | 125 +++++++++ .../Components/RepeatComponentTests.swift | 253 ++++++++++++++++++ 3 files changed, 392 insertions(+) create mode 100644 Sources/OpenGestures/Component/Components/RepeatComponent.swift create mode 100644 Tests/OpenGesturesTests/Component/Components/RepeatComponentTests.swift diff --git a/Sources/OpenGestures/Component/Components/ExpirationComponent.swift b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift index 99c4f53..99a04cf 100644 --- a/Sources/OpenGestures/Component/Components/ExpirationComponent.swift +++ b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift @@ -179,6 +179,20 @@ package enum ExpirablePayload: NestedCustomStringConvertible, S // MARK: - GestureOutput + ExpirationRecord extension GestureOutput { + package static func value( + _ value: Value, + isFinal: Bool, + expiration: Expiration? + ) -> GestureOutput> { + .value( + ExpirationRecord( + payload: .value(value), + expiration: expiration + ), + isFinal: isFinal + ) + } + package func expired( with expiration: Expiration? ) -> GestureOutput> { diff --git a/Sources/OpenGestures/Component/Components/RepeatComponent.swift b/Sources/OpenGestures/Component/Components/RepeatComponent.swift new file mode 100644 index 0000000..f973abb --- /dev/null +++ b/Sources/OpenGestures/Component/Components/RepeatComponent.swift @@ -0,0 +1,125 @@ +// +// RepeatComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - RepeatComponent + +package struct RepeatComponent: Sendable where Upstream: GestureComponent { + package struct State: GestureComponentState, NestedCustomStringConvertible, Sendable { + package var currentCount: Int + package var repeatDeadline: Timestamp? + package var repeatStartTime: Timestamp? + + package init() { + currentCount = 0 + repeatDeadline = nil + repeatStartTime = nil + } + + package init( + currentCount: Int, + repeatDeadline: Timestamp?, + repeatStartTime: Timestamp? + ) { + self.currentCount = currentCount + self.repeatDeadline = repeatDeadline + self.repeatStartTime = repeatStartTime + } + + package var repeatExpiration: Expiration? { + guard let repeatDeadline else { + return nil + } + return Expiration( + deadline: repeatDeadline, + reason: "Repeat deadline expired" + ) + } + } + + package var upstream: Upstream + package var state: State + package let count: Int + package let delay: Duration + + package init( + upstream: Upstream, + state: State = State(), + count: Int, + delay: Duration + ) { + self.upstream = upstream + self.state = state + self.count = count + self.delay = delay + } +} + +// MARK: - RepeatComponent + GestureComponent + +extension RepeatComponent: GestureComponent { + package typealias Value = ExpirationRecord + + package mutating func update(context: GestureComponentContext) throws -> GestureOutput { + if state.currentCount > 0, + state.repeatStartTime == nil, + case .event = context.updateSource { + state.repeatStartTime = context.currentTime + } + var newContext = context + if let repeatStartTime = state.repeatStartTime { + newContext.startTime = repeatStartTime + } + let output = try upstream.tracingUpdate(context: newContext) + guard let value = output.value else { + return .empty(output.emptyReason!, metadata: output.metadata) + } + var newOutput = Self.makeExpirationOutputForNonEmptyOutput( + output, + repeatComponent: &self, + value: value, + context: context + ) + newOutput.metadata = output.metadata ?? GestureOutputMetadata() + return newOutput + } + + private static func makeExpirationOutputForNonEmptyOutput( + _ output: GestureOutput, + repeatComponent: inout Self, + value: Upstream.Value, + context: GestureComponentContext + ) -> GestureOutput { + guard output.isFinal else { + return GestureOutput.value( + value, + isFinal: false, + expiration: repeatComponent.state.repeatExpiration + ) + } + repeatComponent.state.currentCount += 1 + guard repeatComponent.state.currentCount < repeatComponent.count else { + return GestureOutput + .finalValue(value, metadata: nil) + .expired(with: nil) + } + repeatComponent.upstream.reset() + repeatComponent.state.repeatStartTime = nil + let repeatDeadline = context.currentTime + repeatComponent.delay + repeatComponent.state.repeatDeadline = repeatDeadline + return GestureOutput.value( + value, + isFinal: false, + expiration: repeatComponent.state.repeatExpiration + ) + } +} + +// MARK: - RepeatComponent + Component Protocols + +extension RepeatComponent: CompositeGestureComponent {} + +extension RepeatComponent: StatefulGestureComponent {} diff --git a/Tests/OpenGesturesTests/Component/Components/RepeatComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/RepeatComponentTests.swift new file mode 100644 index 0000000..ce0df32 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/RepeatComponentTests.swift @@ -0,0 +1,253 @@ +// +// RepeatComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - RepeatComponentTests + +@Suite +struct RepeatComponentTests { + @Test + func finalValueBeforeRequiredCountProducesRepeatExpiration() throws { + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .finalValue(7, metadata: nil), + ]), + count: 2, + delay: .seconds(1) + ) + + let output = try component.update( + context: repeatComponentContext(currentTime: .seconds(3)) + ) + + guard case let .value(record, metadata) = output else { + Issue.record("Expected repeat value output") + return + } + guard case let .value(value) = record.payload else { + Issue.record("Expected value repeat payload") + return + } + #expect(value == 7) + #expect(record.expiration?.deadline == Timestamp(value: .seconds(4))) + #expect(record.expiration?.reason.description == "Repeat deadline expired") + #expect(component.state.currentCount == 1) + #expect(component.state.repeatDeadline == Timestamp(value: .seconds(4))) + #expect(component.state.repeatStartTime == nil) + #expect(component.upstream.resetCount == 1) + #expect(metadata != nil) + #expect(metadata?.traceAnnotation == nil) + } + + @Test + func finalValueAtRequiredCountCompletes() throws { + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .finalValue(7, metadata: nil), + ]), + state: RepeatComponent.State( + currentCount: 1, + repeatDeadline: Timestamp(value: .seconds(4)), + repeatStartTime: Timestamp(value: .seconds(3)) + ), + count: 2, + delay: .seconds(1) + ) + + let output = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + guard case let .finalValue(record, metadata) = output else { + Issue.record("Expected final repeat output") + return + } + guard case let .value(value) = record.payload else { + Issue.record("Expected value payload") + return + } + #expect(value == 7) + #expect(record.expiration == nil) + #expect(component.state.currentCount == 2) + #expect(component.state.repeatDeadline == Timestamp(value: .seconds(4))) + #expect(component.state.repeatStartTime == Timestamp(value: .seconds(3))) + #expect(metadata != nil) + #expect(metadata?.traceAnnotation == nil) + } + + @Test + func emptyOutputPassesThroughWithoutExpirationRecord() throws { + let metadata = GestureOutputMetadata(traceAnnotation: UpdateTraceAnnotation(value: "empty")) + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .empty(.filtered, metadata: metadata), + ]), + state: RepeatComponent.State( + currentCount: 1, + repeatDeadline: Timestamp(value: .seconds(5)), + repeatStartTime: Timestamp(value: .seconds(3)) + ), + count: 2, + delay: .seconds(1) + ) + + let output = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + guard case let .empty(reason, outputMetadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .filtered) + #expect(outputMetadata?.traceAnnotation?.value == "empty") + } + + @Test + func nonFinalValueCarriesRepeatExpiration() throws { + let metadata = GestureOutputMetadata(traceAnnotation: UpdateTraceAnnotation(value: "value")) + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .value(9, metadata: metadata), + ]), + state: RepeatComponent.State( + currentCount: 1, + repeatDeadline: Timestamp(value: .seconds(5)), + repeatStartTime: Timestamp(value: .seconds(3)) + ), + count: 2, + delay: .seconds(1) + ) + + let output = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + guard case let .value(record, outputMetadata) = output else { + Issue.record("Expected value output") + return + } + guard case let .value(value) = record.payload else { + Issue.record("Expected value payload") + return + } + #expect(value == 9) + #expect(record.expiration?.deadline == Timestamp(value: .seconds(5))) + #expect(outputMetadata != nil) + #expect(outputMetadata?.traceAnnotation == nil) + #expect(component.upstream.contexts.first?.startTime == Timestamp(value: .seconds(3))) + } + + @Test + func nonFinalValueUsesRepeatDeadlineWhenPresent() throws { + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .value(9, metadata: nil), + ]), + state: RepeatComponent.State( + currentCount: 0, + repeatDeadline: Timestamp(value: .seconds(5)), + repeatStartTime: nil + ), + count: 2, + delay: .seconds(1) + ) + + let output = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + guard case let .value(record, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(record.expiration?.deadline == Timestamp(value: .seconds(5))) + #expect(record.expiration?.reason.description == "Repeat deadline expired") + #expect(metadata != nil) + #expect(metadata?.traceAnnotation == nil) + } + + @Test + func existingRepeatStartTimeAdjustsContextWhenCurrentCountIsZero() throws { + let repeatStartTime = Timestamp(value: .seconds(2)) + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .value(9, metadata: nil), + ]), + state: RepeatComponent.State( + currentCount: 0, + repeatDeadline: nil, + repeatStartTime: repeatStartTime + ), + count: 2, + delay: .seconds(1) + ) + + _ = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + #expect(component.state.repeatStartTime == repeatStartTime) + #expect(component.upstream.contexts.first?.startTime == repeatStartTime) + } + + @Test + func firstEventAfterRepeatCapturesRepeatStartTime() throws { + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .value(9, metadata: nil), + ]), + state: RepeatComponent.State( + currentCount: 1, + repeatDeadline: Timestamp(value: .seconds(5)), + repeatStartTime: nil + ), + count: 2, + delay: .seconds(1) + ) + + _ = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + #expect(component.state.repeatStartTime == Timestamp(value: .seconds(4))) + #expect(component.upstream.contexts.first?.startTime == Timestamp(value: .seconds(4))) + } +} + +private func repeatComponentContext(currentTime: Duration) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: currentTime), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct RepeatStubComponent: GestureComponent { + var outputs: [GestureOutput] + var contexts: [GestureComponentContext] = [] + var resetCount = 0 + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + contexts.append(context) + return outputs.removeFirst() + } + + mutating func reset() { + resetCount += 1 + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} From 6d906a2aeaf0fb60c1ec91eb070d74a566101c73 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 10 May 2026 14:41:25 +0800 Subject: [PATCH 19/20] Fix final value transform handling --- .../OpenGestures/Component/ValueTransformingComponent.swift | 2 +- Sources/OpenGestures/Util/Interpolatable.swift | 2 ++ .../Component/Components/ThresholdComponentTests.swift | 3 ++- .../Component/Gate/DurationGateTests.swift | 3 ++- .../Component/Gate/MovementGateTests.swift | 3 ++- .../Component/ValueTransformingComponentTests.swift | 6 ++++-- 6 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/OpenGestures/Component/ValueTransformingComponent.swift b/Sources/OpenGestures/Component/ValueTransformingComponent.swift index b0b515b..ef299c5 100644 --- a/Sources/OpenGestures/Component/ValueTransformingComponent.swift +++ b/Sources/OpenGestures/Component/ValueTransformingComponent.swift @@ -28,7 +28,7 @@ extension ValueTransformingComponent { output.metadata = metadata return output case let .finalValue(value, metadata): - var output = try transform(value, isFinal: false, context: context) + var output = try transform(value, isFinal: true, context: context) output.metadata = metadata return output } diff --git a/Sources/OpenGestures/Util/Interpolatable.swift b/Sources/OpenGestures/Util/Interpolatable.swift index 59966ed..994247d 100644 --- a/Sources/OpenGestures/Util/Interpolatable.swift +++ b/Sources/OpenGestures/Util/Interpolatable.swift @@ -5,6 +5,8 @@ // Audited for 9126.1.5 // Status: Complete +import OpenCoreGraphicsShims + // MARK: - Interpolatable package protocol Interpolatable: Sendable { diff --git a/Tests/OpenGesturesTests/Component/Components/ThresholdComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/ThresholdComponentTests.swift index bf6a425..ffc680d 100644 --- a/Tests/OpenGesturesTests/Component/Components/ThresholdComponentTests.swift +++ b/Tests/OpenGesturesTests/Component/Components/ThresholdComponentTests.swift @@ -30,7 +30,8 @@ struct ThresholdComponentTests { return } #expect(reason == .filtered) - #expect(filteredMetadata?.traceAnnotation?.value == "not enough movement") + #expect(filteredMetadata != nil) + #expect(filteredMetadata?.traceAnnotation == nil) #expect(component.state.initialValue == CGPoint.zero) guard case let .value(value, valueMetadata) = valueOutput else { diff --git a/Tests/OpenGesturesTests/Component/Gate/DurationGateTests.swift b/Tests/OpenGesturesTests/Component/Gate/DurationGateTests.swift index 0377cf8..98dffc9 100644 --- a/Tests/OpenGesturesTests/Component/Gate/DurationGateTests.swift +++ b/Tests/OpenGesturesTests/Component/Gate/DurationGateTests.swift @@ -34,7 +34,8 @@ struct DurationGateTests { return } #expect(reason == .filtered) - #expect(metadata?.traceAnnotation?.value == "min duration not reached") + #expect(metadata != nil) + #expect(metadata?.traceAnnotation == nil) #expect(record.expiration?.deadline == Timestamp(value: .seconds(2))) #expect(record.expiration?.reason.description == "min duration expired") } diff --git a/Tests/OpenGesturesTests/Component/Gate/MovementGateTests.swift b/Tests/OpenGesturesTests/Component/Gate/MovementGateTests.swift index 38d564a..be59b29 100644 --- a/Tests/OpenGesturesTests/Component/Gate/MovementGateTests.swift +++ b/Tests/OpenGesturesTests/Component/Gate/MovementGateTests.swift @@ -31,7 +31,8 @@ struct MovementGateTests { return } #expect(reason == .filtered) - #expect(filteredMetadata?.traceAnnotation?.value == "not enough movement") + #expect(filteredMetadata != nil) + #expect(filteredMetadata?.traceAnnotation == nil) guard case let .finalValue(finalValue, finalMetadata) = finalOutput else { Issue.record("Expected final value output") diff --git a/Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift b/Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift index 0904f63..15996dc 100644 --- a/Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift +++ b/Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift @@ -53,14 +53,16 @@ struct ValueTransformingComponentTests { return } #expect(reason == .filtered) - #expect(valueMetadata?.traceAnnotation?.value == "not final event") + #expect(valueMetadata != nil) + #expect(valueMetadata?.traceAnnotation == nil) guard case let .finalValue(finalValue, finalMetadata) = finalOutput else { Issue.record("Expected final value output") return } #expect(finalValue == 5) - #expect(finalMetadata == nil) + #expect(finalMetadata != nil) + #expect(finalMetadata?.traceAnnotation == nil) } @Test From afe4a1238763a3e47a2ba5b050a50f2b3d993e5f Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 10 May 2026 14:54:14 +0800 Subject: [PATCH 20/20] Replace GestureOutput metadata setter helper --- .../Components/RepeatComponent.swift | 5 ++- .../ValueTransformingComponent.swift | 10 +++--- Sources/OpenGestures/Core/GestureOutput.swift | 35 +++++++++---------- .../Core/GestureOutputTests.swift | 31 ++++++++-------- 4 files changed, 38 insertions(+), 43 deletions(-) diff --git a/Sources/OpenGestures/Component/Components/RepeatComponent.swift b/Sources/OpenGestures/Component/Components/RepeatComponent.swift index f973abb..5b23343 100644 --- a/Sources/OpenGestures/Component/Components/RepeatComponent.swift +++ b/Sources/OpenGestures/Component/Components/RepeatComponent.swift @@ -77,14 +77,13 @@ extension RepeatComponent: GestureComponent { guard let value = output.value else { return .empty(output.emptyReason!, metadata: output.metadata) } - var newOutput = Self.makeExpirationOutputForNonEmptyOutput( + let newOutput = Self.makeExpirationOutputForNonEmptyOutput( output, repeatComponent: &self, value: value, context: context ) - newOutput.metadata = output.metadata ?? GestureOutputMetadata() - return newOutput + return newOutput.copyWithCombinedMetadata(output.metadata ?? GestureOutputMetadata()) } private static func makeExpirationOutputForNonEmptyOutput( diff --git a/Sources/OpenGestures/Component/ValueTransformingComponent.swift b/Sources/OpenGestures/Component/ValueTransformingComponent.swift index ef299c5..f9678b3 100644 --- a/Sources/OpenGestures/Component/ValueTransformingComponent.swift +++ b/Sources/OpenGestures/Component/ValueTransformingComponent.swift @@ -24,13 +24,11 @@ extension ValueTransformingComponent { case let .empty(reason, metadata): return .empty(reason, metadata: metadata) case let .value(value, metadata): - var output = try transform(value, isFinal: false, context: context) - output.metadata = metadata - return output + let output = try transform(value, isFinal: false, context: context) + return output.copyWithCombinedMetadata(metadata) case let .finalValue(value, metadata): - var output = try transform(value, isFinal: true, context: context) - output.metadata = metadata - return output + let output = try transform(value, isFinal: true, context: context) + return output.copyWithCombinedMetadata(metadata) } } } diff --git a/Sources/OpenGestures/Core/GestureOutput.swift b/Sources/OpenGestures/Core/GestureOutput.swift index 9b60c35..f3d2944 100644 --- a/Sources/OpenGestures/Core/GestureOutput.swift +++ b/Sources/OpenGestures/Core/GestureOutput.swift @@ -44,25 +44,24 @@ extension GestureOutput { } package var metadata: GestureOutputMetadata? { - get { - switch self { - case let .empty(_, metadata): - metadata - case let .value(_, metadata): - metadata - case let .finalValue(_, metadata): - metadata - } + switch self { + case let .empty(_, metadata): + metadata + case let .value(_, metadata): + metadata + case let .finalValue(_, metadata): + metadata } - set { - switch self { - case let .empty(reason, _): - self = .empty(reason, metadata: .combineUpdateRequests(metadata, newValue)) - case let .value(value, _): - self = .value(value, metadata: .combineUpdateRequests(metadata, newValue)) - case let .finalValue(value, _): - self = .finalValue(value, metadata: .combineUpdateRequests(metadata, newValue)) - } + } + + package func copyWithCombinedMetadata(_ other: GestureOutputMetadata?) -> Self { + switch self { + case let .empty(reason, metadata): + return .empty(reason, metadata: .combineUpdateRequests(metadata, other)) + case let .value(value, metadata): + return .value(value, metadata: .combineUpdateRequests(metadata, other)) + case let .finalValue(value, metadata): + return .finalValue(value, metadata: .combineUpdateRequests(metadata, other)) } } diff --git a/Tests/OpenGesturesTests/Core/GestureOutputTests.swift b/Tests/OpenGesturesTests/Core/GestureOutputTests.swift index e49d487..eff617b 100644 --- a/Tests/OpenGesturesTests/Core/GestureOutputTests.swift +++ b/Tests/OpenGesturesTests/Core/GestureOutputTests.swift @@ -10,10 +10,10 @@ import Testing @Suite struct GestureOutputTests { - // MARK: - metadata setter + // MARK: - copyWithCombinedMetadata @Test - func metadataSetterCombinesMetadataAndPreservesValueCase() { + func copyWithCombinedMetadataCombinesMetadataAndPreservesValueCase() { let updateToSchedule = UpdateRequest( id: 1, creationTime: Timestamp(value: .seconds(1)), @@ -34,11 +34,10 @@ struct GestureOutputTests { ) ) - var replaced = output - replaced.metadata = GestureOutputMetadata( + let replaced = output.copyWithCombinedMetadata(GestureOutputMetadata( updatesToCancel: [updateToCancel], traceAnnotation: UpdateTraceAnnotation(value: "replacement") - ) + )) guard case let .value(value, metadata) = replaced else { Issue.record("Expected value output") @@ -51,21 +50,21 @@ struct GestureOutputTests { } @Test - func metadataSetterPreservesEmptyAndFinalCases() { - var emptyOutput: GestureOutput = .empty(.filtered, metadata: nil) - var finalOutput: GestureOutput = .finalValue(9, metadata: nil) + func copyWithCombinedMetadataPreservesEmptyAndFinalCases() { + let emptyOutput: GestureOutput = .empty(.filtered, metadata: nil) + let finalOutput: GestureOutput = .finalValue(9, metadata: nil) let replacement = GestureOutputMetadata( traceAnnotation: UpdateTraceAnnotation(value: "replacement") ) - emptyOutput.metadata = replacement - finalOutput.metadata = replacement + let emptyOutputWithMetadata = emptyOutput.copyWithCombinedMetadata(replacement) + let finalOutputWithMetadata = finalOutput.copyWithCombinedMetadata(replacement) - guard case let .empty(reason, emptyMetadata) = emptyOutput else { + guard case let .empty(reason, emptyMetadata) = emptyOutputWithMetadata else { Issue.record("Expected empty output") return } - guard case let .finalValue(value, finalMetadata) = finalOutput else { + guard case let .finalValue(value, finalMetadata) = finalOutputWithMetadata else { Issue.record("Expected final value output") return } @@ -78,11 +77,11 @@ struct GestureOutputTests { } @Test - func metadataSetterKeepsNilWhenBothSidesAreNil() { - var output: GestureOutput = .value(3, metadata: nil) + func copyWithCombinedMetadataKeepsNilWhenBothSidesAreNil() { + let output: GestureOutput = .value(3, metadata: nil) - output.metadata = nil + let copied = output.copyWithCombinedMetadata(nil) - #expect(output.metadata == nil) + #expect(copied.metadata == nil) } }