diff --git a/Sources/OpenGestures/Component/CompositeGestureComponent.swift b/Sources/OpenGestures/Component/CompositeGestureComponent.swift new file mode 100644 index 0000000..657aeeb --- /dev/null +++ b/Sources/OpenGestures/Component/CompositeGestureComponent.swift @@ -0,0 +1,28 @@ +// +// CompositeGestureComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - CompositeGestureComponent + +/// A gesture component that wraps an upstream component, enabling chaining. +public protocol CompositeGestureComponent: GestureComponent { + associatedtype Upstream: GestureComponent + var upstream: Upstream { get set } +} + +extension CompositeGestureComponent { + public mutating func reset() { + upstream.reset() + } + + public func traits() -> GestureTraitCollection? { + upstream.traits() + } + + public func capacity(for eventType: E.Type) -> Int { + upstream.capacity(for: eventType) + } +} diff --git a/Sources/OpenGestures/Component/DiscreteComponent.swift b/Sources/OpenGestures/Component/DiscreteComponent.swift new file mode 100644 index 0000000..ccc7471 --- /dev/null +++ b/Sources/OpenGestures/Component/DiscreteComponent.swift @@ -0,0 +1,10 @@ +// +// DiscreteComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - DiscreteComponent + +public protocol DiscreteComponent: GestureComponent {} diff --git a/Sources/OpenGestures/Component/DiscreteGate.swift b/Sources/OpenGestures/Component/DiscreteGate.swift new file mode 100644 index 0000000..a2706c2 --- /dev/null +++ b/Sources/OpenGestures/Component/DiscreteGate.swift @@ -0,0 +1,50 @@ +// +// DiscreteGate.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - DiscreteGate + +package struct DiscreteGate: Sendable { + package var upstream: Upstream + + package init(upstream: Upstream) { + self.upstream = upstream + } +} + +// MARK: - DiscreteGate + GestureComponent + +extension DiscreteGate: GestureComponent { + package typealias Value = Upstream.Value +} + +// MARK: - DiscreteGate + CompositeGestureComponent + +extension DiscreteGate: CompositeGestureComponent {} + +// MARK: - DiscreteGate + DiscreteComponent + +extension DiscreteGate: DiscreteComponent {} + +// MARK: - DiscreteGate + ValueTransformingComponent + +extension DiscreteGate: ValueTransformingComponent { + package mutating func transform( + _ value: Value, + isFinal: Bool + ) throws -> GestureOutput { + if isFinal { + return .finalValue(value, metadata: nil) + } else { + return .empty( + .filtered, + metadata: GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "not final event") + ) + ) + } + } +} diff --git a/Sources/OpenGestures/Component/GestureComponent.swift b/Sources/OpenGestures/Component/GestureComponent.swift index 2a9c0e7..d893e61 100644 --- a/Sources/OpenGestures/Component/GestureComponent.swift +++ b/Sources/OpenGestures/Component/GestureComponent.swift @@ -1,77 +1,115 @@ +// +// GestureComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + // MARK: - GestureComponent /// A protocol for gesture recognition components. public protocol GestureComponent: Sendable { associatedtype Value: Sendable - - func update(context: GestureComponentContext) throws -> GestureOutput + mutating func update(context: GestureComponentContext) throws -> GestureOutput mutating func reset() func traits() -> GestureTraitCollection? func capacity(for eventType: E.Type) -> Int } -// MARK: - Default capacity +// MARK: - GestureComponent + Tracing extension GestureComponent { - public func capacity(for eventType: E.Type) -> Int { 1 } -} - -// MARK: - StatefulGestureComponent - -/// A gesture component that maintains mutable state across updates. -public protocol StatefulGestureComponent: GestureComponent { - associatedtype State: GestureComponentState - var state: State { get set } -} - -extension StatefulGestureComponent { - public mutating func reset() { - state = State() + package mutating func tracingUpdateResult( + context: GestureComponentContext + ) -> Result, any Error> { + Result { + try tracingUpdate(context: context) + } } -} -// MARK: - CompositeGestureComponent - -/// A gesture component that wraps an upstream component, enabling chaining. -public protocol CompositeGestureComponent: GestureComponent { - associatedtype Upstream: GestureComponent - var upstream: Upstream { get set } -} - -extension CompositeGestureComponent { - /// Default: delegates to upstream.update(context:) - public func update(context: GestureComponentContext) throws -> GestureOutput { - fatalError("CompositeGestureComponent subtype must override update(context:)") + package mutating func tracingUpdate(context: GestureComponentContext) throws -> GestureOutput { + guard let updateTracer = context.updateTracer else { + return try update(context: context) + } + + updateTracer.beginTrace() + let result: Result, any Error> = Result { + try update(context: context) + } + let snapshot = makeTraceDataSnapshot(result: result) + updateTracer.endTrace(snapshot: snapshot) + return try result.get() } - public mutating func reset() { - upstream.reset() + private mutating func makeTraceDataSnapshot( + result: Result, any Error> + ) -> TraceDataSnapshot { + let componentDescription = String(describing: self) + let resultDescription = String(describing: result) + let stateDescription: String + if let stateful = self as? any StatefulGestureComponent { + stateDescription = String(describing: stateful.state) + } else { + stateDescription = "" + } + return TraceDataSnapshot( + component: { componentDescription }, + result: { resultDescription }, + state: { stateDescription }, + isSuccess: { + switch result { + case .success: true + case .failure: false + } + }() + ) } +} - public func traits() -> GestureTraitCollection? { - upstream.traits() +extension CompositeGestureComponent { + public mutating func update( + context: GestureComponentContext + ) throws -> GestureOutput { + try upstream.tracingUpdate(context: context) } } // MARK: - GestureComponentContext /// Context passed to gesture components during update cycles. -public struct GestureComponentContext: Sendable { +public struct GestureComponentContext: @unchecked Sendable { public var startTime: Timestamp public var currentTime: Timestamp + package var updateSource: GestureUpdateSource + package var updateTracer: UpdateTracer? + package var eventStore: AnyEventStore public var durationSinceStart: Duration { startTime.duration(to: currentTime) } - public init(startTime: Timestamp, currentTime: Timestamp) { + package init( + startTime: Timestamp, + currentTime: Timestamp, + updateSource: GestureUpdateSource, + updateTracer: UpdateTracer? = nil, + eventStore: AnyEventStore + ) { self.startTime = startTime self.currentTime = currentTime + self.updateSource = updateSource + self.updateTracer = updateTracer + self.eventStore = eventStore } } -// MARK: - GestureComponentState +// MARK: - GestureUpdateSource + +/// Source that caused a gesture component update cycle. +package enum GestureUpdateSource: Equatable, Sendable { + /// Scheduler-driven update carrying the scheduled request identifiers. + case scheduler(Set) -public protocol GestureComponentState: Sendable { - init() + /// Event-driven update. + case event } diff --git a/Sources/OpenGestures/Component/GestureComponentController.swift b/Sources/OpenGestures/Component/GestureComponentController.swift index ab10aeb..59db93b 100644 --- a/Sources/OpenGestures/Component/GestureComponentController.swift +++ b/Sources/OpenGestures/Component/GestureComponentController.swift @@ -3,30 +3,44 @@ // OpenGestures // // Audited for 9126.1.5 -// Status: WIP +// Status: Complete -// MARK: - GestureComponentController [WIP] +import Dispatch -/// Concrete controller wrapping a specific `GestureComponent`. -public final class GestureComponentController: AnyGestureComponentController, @unchecked Sendable { +// MARK: - GestureComponentController - public var component: C - let timeScheduler: any TimeScheduler - // TODO: var eventStores: [ObjectIdentifier: AnyEventStore] = [:] - var _traits: GestureTraitCollection? - var startTime: Timestamp? - var updateListener: ((Result, any Error>) -> Void)? - // TODO: lazy var updateTracer: UpdateTracer? - lazy var updateScheduler: UpdateScheduler? = nil +public final class GestureComponentController: AnyGestureComponentController, @unchecked Sendable { - public init(component: C, timeScheduler: any TimeScheduler) { + private var component: Component + private let timeScheduler: any TimeScheduler + private var eventStores: [ObjectIdentifier: AnyEventStore] = [:] + private var _traits: GestureTraitCollection? + private var startTime: Timestamp? + private var updateListener: ((Result, any Error>) -> Void)? + private lazy var updateTracer: UpdateTracer? = UpdateTracer() + private lazy var updateScheduler = UpdateScheduler( + timeScheduler: timeScheduler, + scheduledRequests: [:] + ) + + public init(component: Component, timeScheduler: any TimeScheduler) { self.component = component self.timeScheduler = timeScheduler super.init() } + public convenience init(component: Component) { + self.init( + component: component, + timeScheduler: DispatchTimeScheduler(queue: .main, timeSource: UptimeTimeSource()) + ) + } + public override var traits: GestureTraitCollection? { - component.traits() + if _traits == nil { + _traits = component.traits() + } + return _traits } public override var timeSource: any TimeSource { @@ -38,66 +52,139 @@ public final class GestureComponentController: AnyGestureCo } public override func handleEvents(_ events: [E]) throws { - let currentTime = timeScheduler.timestamp - let startTime = self.startTime ?? currentTime - if self.startTime == nil { - self.startTime = currentTime - } - - let context = GestureComponentContext(startTime: startTime, currentTime: currentTime) - let output = try component.update(context: context) - - guard let node else { return } - switch output { - case .empty: - break - case .value(let value, _): - try node.update(someValue: value, isFinalUpdate: false) - case .finalValue(let value, _): - try node.update(someValue: value, isFinalUpdate: true) + let store = eventStore(for: E.self) + store.append(events) + if startTime == nil { + startTime = timeScheduler.timestamp } + try performUpdate(updateSource: .event, eventType: E.self) + store.removeUnboundTerminalEvents() } public override func reset() { + updateScheduler.cancelAll() + for store in eventStores.values { + store.unbindAll() + } component.reset() + _traits = nil startTime = nil } + + private func eventStore(for eventType: E.Type) -> AnyEventStore { + let key = ObjectIdentifier(eventType) + if let store = eventStores[key] { + return store + } + let store = EventStore() + eventStores[key] = store + return store + } + + private func performUpdate( + updateSource: GestureUpdateSource, + eventType: E.Type + ) throws { + let context = GestureComponentContext( + startTime: startTime!, + currentTime: timeScheduler.timestamp, + updateSource: updateSource, + updateTracer: updateTracer, + eventStore: eventStore(for: eventType) + ) + let result = component.tracingUpdateResult(context: context) + if let updateTracer { + updateTracer.logTrace() + updateTracer.reset() + } + if let output = try? result.get() { + try applyOutputMetadata(output, eventType: eventType) + } + if let node { + try dispatch(node, result: result) + } + updateListener?(result) + } + + private func applyOutputMetadata( + _ output: GestureOutput, + eventType: E.Type + ) throws { + guard let metadata = output.metadata else { + return + } + if !metadata.updatesToSchedule.isEmpty { + updateScheduler.schedule( + metadata.updatesToSchedule, + handler: { [weak self] requestIDs in + guard let self else { + return + } + performScheduledUpdate(requestIDs, eventType: eventType) + }, + cancelHandler: nil + ) + } + if !metadata.updatesToCancel.isEmpty { + updateScheduler.cancel(metadata.updatesToCancel) + } + } + + private func performScheduledUpdate( + _ requestIDs: Set, + eventType: E.Type + ) { + do { + try performUpdate( + updateSource: .scheduler(requestIDs), + eventType: eventType + ) + } catch { + Log.logFailedScheduledUpdate() + } + } + + private func dispatch( + _ node: AnyGestureNode, + result: Result, any Error> + ) throws { + switch result { + case let .success(output): + if let value = output.value { + try node.update(someValue: value, isFinalUpdate: output.isFinal) + } + case let .failure(error): + try node.update(reason: .custom(error), isFinalUpdate: false) + } + } } // MARK: - AnyGestureComponentController -/// Type-erased base for gesture component controllers. open class AnyGestureComponentController: @unchecked Sendable { - /// Weak back-reference to the owning gesture node. open weak var node: AnyGestureNode? - /// Traits exposed by the wrapped component. open var traits: GestureTraitCollection? { _openGesturesBaseClassAbstractMethod() } - /// Time source used by `handleEvents` to build `GestureComponentContext`. open var timeSource: any TimeSource { _openGesturesBaseClassAbstractMethod() } - /// Whether this controller can consume `count` events of the given type. open func canHandleEvents(ofType: E.Type, count: Int) -> Bool { _openGesturesBaseClassAbstractMethod() } - /// Whether this controller can consume a single event. open func canHandleEvent(_ event: E) -> Bool { _openGesturesBaseClassAbstractMethod() } - /// Drives the wrapped component with the given events. open func handleEvents(_ events: [E]) throws { _openGesturesBaseClassAbstractMethod() } - /// Resets the controller and its wrapped component. open func reset() { _openGesturesBaseClassAbstractMethod() } diff --git a/Sources/OpenGestures/Component/GestureComponentState.swift b/Sources/OpenGestures/Component/GestureComponentState.swift new file mode 100644 index 0000000..a9eaeb7 --- /dev/null +++ b/Sources/OpenGestures/Component/GestureComponentState.swift @@ -0,0 +1,33 @@ +// +// GestureComponentState.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - StatefulGestureComponent + +/// A gesture component that maintains mutable state across updates. +public protocol StatefulGestureComponent: GestureComponent { + associatedtype State: GestureComponentState + var state: State { get set } +} + +extension StatefulGestureComponent { + public mutating func reset() { + state = State() + } +} + +extension CompositeGestureComponent where Self: StatefulGestureComponent { + public mutating func reset() { + upstream.reset() + state = State() + } +} + +// MARK: - GestureComponentState + +public protocol GestureComponentState: Sendable { + init() +} diff --git a/Sources/OpenGestures/Component/LongPressComponent.swift b/Sources/OpenGestures/Component/LongPressComponent.swift index fef6c31..58b9665 100644 --- a/Sources/OpenGestures/Component/LongPressComponent.swift +++ b/Sources/OpenGestures/Component/LongPressComponent.swift @@ -1,36 +1,43 @@ -// MARK: - LongPressComponent - -/// A gesture component that recognizes long press gestures. -public struct LongPressComponent: Sendable where Upstream: Sendable { - public var maximumMovement: Double - public var maximumSeparationDistance: Double - public var pointCountTimeout: Duration - public var minimumDuration: Duration - public var maximumDuration: Duration - public var pointCount: Int - public var failOnExceedingMaximumPointCount: Bool - public var upstream: Upstream -} - -// MARK: - CompositeGestureComponent - -extension LongPressComponent: CompositeGestureComponent { - public typealias Value = Upstream.Value - - public func traits() -> GestureTraitCollection? { - .withTrait(.longPress( - pointCount: pointCount, - minimumDuration: minimumDuration, - maximumMovement: maximumMovement - )) - } - - public func update(context: GestureComponentContext) throws -> GestureOutput { - // TODO: Full long press recognition - try upstream.update(context: context) - } - - public mutating func reset() { - upstream.reset() - } -} +//// +//// LongPressComponent.swift +//// OpenGestures +//// +//// Audited for 9126.1.5 +//// Status: WIP +// +//// MARK: - LongPressComponent +// +///// A gesture component that recognizes long press gestures. +//public struct LongPressComponent: Sendable where Upstream: Sendable { +// public var maximumMovement: Double +// public var maximumSeparationDistance: Double +// public var pointCountTimeout: Duration +// public var minimumDuration: Duration +// public var maximumDuration: Duration +// public var pointCount: Int +// public var failOnExceedingMaximumPointCount: Bool +// public var upstream: Upstream +//} +// +//// MARK: - CompositeGestureComponent +// +//extension LongPressComponent: CompositeGestureComponent { +// public typealias Value = Upstream.Value +// +// public func traits() -> GestureTraitCollection? { +// .withTrait(.longPress( +// pointCount: pointCount, +// minimumDuration: minimumDuration, +// maximumMovement: maximumMovement +// )) +// } +// +// public mutating func update(context: GestureComponentContext) throws -> GestureOutput { +// // TODO: Full long press recognition +// try upstream.update(context: context) +// } +// +// public mutating func reset() { +// upstream.reset() +// } +//} diff --git a/Sources/OpenGestures/Component/MovementGate.swift b/Sources/OpenGestures/Component/MovementGate.swift new file mode 100644 index 0000000..d215056 --- /dev/null +++ b/Sources/OpenGestures/Component/MovementGate.swift @@ -0,0 +1,78 @@ +// +// MovementGate.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - MovementGate + +package struct MovementGate: Sendable + where Upstream: GestureComponent, + LocationValue: LocationContaining & Sendable, + Upstream.Value == TrackedValue +{ + package enum Failure: Error, Hashable, Sendable { + case tooMuchMovement + } + + package enum Restriction: Hashable, Sendable { + case min + case max + } + + package var upstream: Upstream + package let bound: Double + package let restriction: Restriction + + package init( + upstream: Upstream, + bound: Double, + restriction: Restriction + ) { + self.upstream = upstream + self.bound = bound + self.restriction = restriction + } +} + +// MARK: - MovementGate + GestureComponent + +extension MovementGate: GestureComponent { + package typealias Value = TrackedValue +} + +// MARK: - MovementGate + CompositeGestureComponent + +extension MovementGate: CompositeGestureComponent {} + +// MARK: - MovementGate + ValueTransformingComponent + +extension MovementGate: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool + ) throws -> GestureOutput { + let movement = value.locationTranslation.magnitude + switch restriction { + case .min: + if movement < bound { + return .empty( + .filtered, + metadata: GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "not enough movement") + ) + ) + } + case .max: + if movement > bound { + throw Failure.tooMuchMovement + } + } + if isFinal { + return .finalValue(value, metadata: nil) + } else { + return .value(value, metadata: nil) + } + } +} diff --git a/Sources/OpenGestures/Component/PanComponent.swift b/Sources/OpenGestures/Component/PanComponent.swift index 5e84d03..8ea6fdd 100644 --- a/Sources/OpenGestures/Component/PanComponent.swift +++ b/Sources/OpenGestures/Component/PanComponent.swift @@ -1,61 +1,86 @@ -public import OpenCoreGraphicsShims - -// MARK: - PanComponent - -/// A gesture component that recognizes pan (drag) gestures. -public struct PanComponent: Sendable where Upstream: Sendable { - public var hysteresis: Double - public var maximumSeparationDistance: Double - public var pointCountTimeout: Duration - public var minimumPointCount: Int - public var maximumPointCount: Int - public var failOnExceedingMaximumPointCount: Bool - public var invertScrollingDirection: Bool - public var preferNonAcceleratedScrollingDelta: Bool - public var ignoreStationaryPoints: Bool - public var upstream: Upstream -} - -// MARK: - CompositeGestureComponent - -extension PanComponent: CompositeGestureComponent { - public typealias Value = Upstream.Value - - public func traits() -> GestureTraitCollection? { - .withTrait(.pan()) - } - - public func update(context: GestureComponentContext) throws -> GestureOutput { - // TODO: Full pan recognition (hysteresis, translation, velocity) - try upstream.update(context: context) - } - - public mutating func reset() { - upstream.reset() - } -} - -// MARK: - PanComponentValue - -/// Value produced by a pan gesture, including location, translation, and velocity. -public struct PanComponentValue: Sendable { - public var location: CGPoint - public var translation: CGVector - public var velocity: CGVector - public var predictedEndLocation: CGPoint - public var predictedEndTranslation: CGVector - - public init( - location: CGPoint, - translation: CGVector, - velocity: CGVector, - predictedEndLocation: CGPoint, - predictedEndTranslation: CGVector - ) { - self.location = location - self.translation = translation - self.velocity = velocity - self.predictedEndLocation = predictedEndLocation - self.predictedEndTranslation = predictedEndTranslation - } -} +//// +//// PanComponent.swift +//// OpenGestures +//// +//// Audited for 9126.1.5 +//// Status: WIP +// +//public import OpenCoreGraphicsShims +// +//// MARK: - PanComponent +// +///// A gesture component that recognizes pan (drag) gestures. +//public struct PanComponent: Sendable where Upstream: Sendable { +// public var hysteresis: Double +// public var maximumSeparationDistance: Double +// public var pointCountTimeout: Duration +// public var minimumPointCount: Int +// public var maximumPointCount: Int +// public var failOnExceedingMaximumPointCount: Bool +// public var invertScrollingDirection: Bool +// public var preferNonAcceleratedScrollingDelta: Bool +// public var ignoreStationaryPoints: Bool +// public var upstream: Upstream +//} +// +//// MARK: - CompositeGestureComponent +// +//extension PanComponent: CompositeGestureComponent { +// public typealias Value = Upstream.Value +// +// public func traits() -> GestureTraitCollection? { +// .withTrait(.pan()) +// } +// +// public mutating func update(context: GestureComponentContext) throws -> GestureOutput { +// // TODO: Full pan recognition (hysteresis, translation, velocity) +// try upstream.update(context: context) +// } +// +// public mutating func reset() { +// upstream.reset() +// } +//} +// +//// MARK: - PanComponentValue +// +///// Value produced by a pan gesture, including location, translation, and velocity. +//public struct PanComponentValue: Sendable { +// public var location: CGPoint +// public var translation: CGVector +// package var _velocity: CGVector +// +// public var velocity: CGVector { +// let predicted = predictedEndLocation +// return CGVector( +// dx: (predicted.x - location.x) * 4.0, +// dy: (predicted.y - location.y) * 4.0 +// ) +// } +// +// public var predictedEndLocation: CGPoint { +// CGPoint( +// x: location.x + _velocity.dx * 0.25, +// y: location.y + _velocity.dy * 0.25 +// ) +// } +// +// public var predictedEndTranslation: CGVector { +// CGVector( +// dx: translation.dx + _velocity.dx * 0.25, +// dy: translation.dy + _velocity.dy * 0.25 +// ) +// } +// +// public init( +// location: CGPoint, +// translation: CGVector, +// velocity: CGVector +// ) { +// self.location = location +// self.translation = translation +// self._velocity = velocity +// } +//} +// +//extension PanComponentValue: NestedCustomStringConvertible {} diff --git a/Sources/OpenGestures/Component/TapComponent.swift b/Sources/OpenGestures/Component/TapComponent.swift index 28b4870..1486ec9 100644 --- a/Sources/OpenGestures/Component/TapComponent.swift +++ b/Sources/OpenGestures/Component/TapComponent.swift @@ -1,34 +1,41 @@ -// MARK: - TapComponent - -/// A gesture component that recognizes tap gestures. -public struct TapComponent: Sendable where Upstream: Sendable { - public var maximumMovement: Double - public var maximumSeparationDistance: Double - public var tapInterval: Duration - public var pointCountTimeout: Duration - public var minimumDuration: Duration - public var maximumDuration: Duration - public var tapCount: Int - public var pointCount: Int - public var failOnExceedingMaximumPointCount: Bool - public var upstream: Upstream -} - -// MARK: - CompositeGestureComponent - -extension TapComponent: CompositeGestureComponent { - public typealias Value = Upstream.Value - - public func traits() -> GestureTraitCollection? { - .withTrait(.tap(tapCount: tapCount)) - } - - public func update(context: GestureComponentContext) throws -> GestureOutput { - // TODO: Full tap recognition state machine - try upstream.update(context: context) - } - - public mutating func reset() { - upstream.reset() - } -} +//// +//// TapComponent.swift +//// OpenGestures +//// +//// Audited for 9126.1.5 +//// Status: WIP +// +//// MARK: - TapComponent +// +///// A gesture component that recognizes tap gestures. +//public struct TapComponent: Sendable where Upstream: Sendable { +// public var maximumMovement: Double +// public var maximumSeparationDistance: Double +// public var tapInterval: Duration +// public var pointCountTimeout: Duration +// public var minimumDuration: Duration +// public var maximumDuration: Duration +// public var tapCount: Int +// public var pointCount: Int +// public var failOnExceedingMaximumPointCount: Bool +// public var upstream: Upstream +//} +// +//// MARK: - CompositeGestureComponent +// +//extension TapComponent: CompositeGestureComponent { +// public typealias Value = Upstream.Value +// +// public func traits() -> GestureTraitCollection? { +// .withTrait(.tap(tapCount: tapCount, pointCount: pointCount)) +// } +// +// public mutating func update(context: GestureComponentContext) throws -> GestureOutput { +// // TODO: Full tap recognition state machine +// try upstream.update(context: context) +// } +// +// public mutating func reset() { +// upstream.reset() +// } +//} diff --git a/Sources/OpenGestures/Component/TrackedValue.swift b/Sources/OpenGestures/Component/TrackedValue.swift new file mode 100644 index 0000000..4d10db1 --- /dev/null +++ b/Sources/OpenGestures/Component/TrackedValue.swift @@ -0,0 +1,45 @@ +// +// TrackedValue.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +#if !canImport(Darwin) +package import OpenCoreGraphicsShims +#endif + +// MARK: - TrackedValue + +@frozen +package struct TrackedValue: Sendable { + package var current: Value + package var previous: Value? + package var initial: Value + + package init(current: Value, previous: Value?, initial: Value) { + self.current = current + self.previous = previous + self.initial = initial + } +} + +// MARK: - TrackedValue + NestedCustomStringConvertible + +extension TrackedValue: NestedCustomStringConvertible {} + +// MARK: - TrackedValue + LocationContaining + +extension TrackedValue: LocationContaining where Value: LocationContaining { + package var location: CGPoint { + current.location + } + + package var locationTranslation: CGPoint { + current.location - initial.location + } +} + +// MARK: - TrackedValue + Equatable + +extension TrackedValue: Equatable where Value: Equatable {} diff --git a/Sources/OpenGestures/Component/UpdateTracer.swift b/Sources/OpenGestures/Component/UpdateTracer.swift new file mode 100644 index 0000000..aad0fd0 --- /dev/null +++ b/Sources/OpenGestures/Component/UpdateTracer.swift @@ -0,0 +1,296 @@ +// +// UpdateTracer.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +#if canImport(os) +import os +#endif + +// MARK: - UpdateTracer + +/// Records nested gesture component updates and renders the resulting trace tree. +/// +/// An update trace starts with ``beginTrace()`` and is completed with +/// ``endTrace(snapshot:)``. Completed traces are linked through +/// ``Trace/upstreamTraces`` so the final head trace can be rendered and emitted +/// by ``logTrace()``. +package class UpdateTracer: @unchecked Sendable { + /// The monotonically increasing identifier assigned to newly started traces. + package var seed: Int16 + + /// The most recently completed trace tree waiting to be attached or logged. + package var traceHead: Trace? + + /// The stack of traces currently being updated. + package var activeTraces: [Trace] + + /// Completed trace heads waiting for their active parent trace to finish. + package var pendingBranches: [Int16: [Trace]] + + /// Snapshot data keyed by trace identifier. + package var dataSnapshots: [Int16: TraceDataSnapshot] + + /// Creates an update tracer with the supplied internal state. + /// + /// - Parameters: + /// - seed: The current trace identifier seed. + /// - traceHead: The most recently completed trace tree, if any. + /// - activeTraces: The stack of in-flight traces. + /// - pendingBranches: Completed trace heads waiting for a parent trace. + /// - dataSnapshots: Snapshot data keyed by trace identifier. + package init( + seed: Int16 = 0, + traceHead: Trace? = nil, + activeTraces: [Trace] = [], + pendingBranches: [Int16: [Trace]] = [:], + dataSnapshots: [Int16: TraceDataSnapshot] = [:] + ) { + self.seed = seed + self.traceHead = traceHead + self.activeTraces = activeTraces + self.pendingBranches = pendingBranches + self.dataSnapshots = dataSnapshots + } + + /// Starts a new active trace. + /// + /// If a completed trace head already exists while another trace is active, + /// the head is staged as a pending upstream branch for the active trace. + package func beginTrace() { + seed = seed &+ 1 + if let traceHead, let activeTrace = activeTraces.last { + pendingBranches[activeTrace.id, default: []].append(traceHead) + self.traceHead = nil + } + activeTraces.append(Trace(id: seed)) + } + + /// Completes the current active trace with its captured snapshot. + /// + /// The completed trace becomes ``traceHead`` after attaching any previous + /// trace head and pending branches as upstream traces. + /// + /// - Parameter snapshot: The captured component, state, and result summary + /// for the current trace. + package func endTrace(snapshot: TraceDataSnapshot) { + var trace = activeTraces.popLast()! + dataSnapshots[trace.id] = snapshot + if let traceHead { + trace.upstreamTraces.append(traceHead) + } + if let pending = pendingBranches.removeValue(forKey: trace.id) { + trace.upstreamTraces.append(contentsOf: pending) + } + traceHead = trace + } + + /// Emits the current trace tree to the component update log. + /// + /// If there is no completed trace head, this method returns without logging. + package func logTrace() { + guard let traceHead else { + return + } + let renderedTrace = traceHead.rendered(using: dataSnapshots) + #if canImport(os) + Log.componentUpdates.log("\(renderedTrace)") + #else + // TODO: Add swift-log library support + print(renderedTrace) + #endif + } + + /// Clears all recorded trace state. + /// + /// Calling this on a fresh tracer is a no-op. + package func reset() { + guard seed != 0 else { + return + } + seed = 0 + traceHead = nil + activeTraces = [] + pendingBranches = [:] + dataSnapshots = [:] + } +} + +// MARK: - TraceDataSnapshot + +/// Lazily captured strings used to render a completed trace node. +package struct TraceDataSnapshot: Sendable { + /// Returns the component description for the trace node. + package var component: @Sendable () -> String + + /// Returns the output or error description for the trace node. + package var result: @Sendable () -> String + + /// Returns the component state description for the trace node. + package var state: @Sendable () -> String + + /// Indicates whether ``result`` should be rendered as output or error text. + package var isSuccess: Bool + + /// Creates a trace data snapshot. + /// + /// - Parameters: + /// - component: A closure returning the component description. + /// - result: A closure returning the output or error description. + /// - state: A closure returning the component state description. + /// - isSuccess: Whether `result` should use the `Output` or `Error` + /// render label. + package init( + component: @escaping @Sendable () -> String, + result: @escaping @Sendable () -> String, + state: @escaping @Sendable () -> String, + isSuccess: Bool + ) { + self.component = component + self.result = result + self.state = state + self.isSuccess = isSuccess + } +} + +// MARK: - Trace + +/// A node in an update trace tree. +package struct Trace: Hashable, Identifiable, Sendable { + /// The identifier used to find this node's ``TraceDataSnapshot``. + package var id: Int16 + + /// Traces that fed into this trace and are rendered as upstream branches. + package var upstreamTraces: [Trace] + + /// Creates a trace node. + /// + /// - Parameters: + /// - id: The identifier used to find this trace's snapshot. + /// - upstreamTraces: The upstream traces to render beneath this node. + package init(id: Int16, upstreamTraces: [Trace] = []) { + self.id = id + self.upstreamTraces = upstreamTraces + } +} + +// MARK: - Trace + Rendering + +extension Trace { + /// Renders this trace tree using the supplied snapshot data. + /// + /// The rendered tree includes the component summary, an optional result + /// summary, and any upstream traces. If no snapshot exists for a trace + /// identifier, the node is rendered as `UnknownComponent`. + /// + /// - Parameters: + /// - snapshots: Snapshot data keyed by trace identifier. + /// - prefix: Text prepended to the first rendered line for this trace. + /// - childPrefix: Text prepended to continuation lines and child traces. + /// - Returns: The rendered trace tree. + package func rendered( + using snapshots: [Int16: TraceDataSnapshot], + prefix: String = "", + childPrefix: String = "" + ) -> String { + let chunks = Self.renderedChunks(for: snapshots[id]) + let prefixedComponentSummary = Self.prefixedChunk( + chunks.componentSummary, + prefix: childPrefix, + preservingFirstLine: true + ) + let prefixedResultSummary = Self.prefixedChunk( + chunks.resultSummary, + prefix: childPrefix, + preservingFirstLine: false + ) + var result = prefix + result += prefixedComponentSummary + result += "\n" + result += prefixedResultSummary + result += "\n" + guard !upstreamTraces.isEmpty else { + return result + } + result += "\(childPrefix)↑\n\(childPrefix)│\n" + for (index, upstreamTrace) in upstreamTraces.enumerated() { + let isLast = index == upstreamTraces.index(before: upstreamTraces.endIndex) + let branch = isLast ? "└── " : "├── " + let continuation = isLast ? " " : "│ " + result += upstreamTrace.rendered( + using: snapshots, + prefix: childPrefix + branch, + childPrefix: childPrefix + continuation + ) + if !isLast { + result += childPrefix + "│\n" + } + } + return result + } + + /// Formats an optional snapshot into the two text chunks used by a trace node. + /// + /// Missing snapshots render as `UnknownComponent` with an empty result + /// summary. Present snapshots trim generic arguments from the component + /// summary, append the state suffix, and render nonempty results with either + /// the `Output` or `Error` label. + /// + /// - Parameter snapshot: The snapshot to format, or `nil` for an unknown + /// component. + /// - Returns: The component summary and optional result summary. + private static func renderedChunks( + for snapshot: TraceDataSnapshot? + ) -> (componentSummary: String, resultSummary: String) { + guard let snapshot else { + return ("UnknownComponent", "") + } + var componentSummary = snapshot.component() + if let genericStart = componentSummary.firstIndex(of: "<") { + componentSummary = String(componentSummary[.. String { + guard !chunk.isEmpty else { + return "" + } + let lines = chunk.split(separator: "\n", omittingEmptySubsequences: true) + if preservingFirstLine { + guard let firstLine = lines.first else { + return "" + } + return ([String(firstLine)] + lines.dropFirst().map { prefix + $0 }) + .joined(separator: "\n") + } else { + return lines + .map { prefix + $0 } + .joined(separator: "\n") + } + } +} diff --git a/Sources/OpenGestures/Component/ValueTracker.swift b/Sources/OpenGestures/Component/ValueTracker.swift new file mode 100644 index 0000000..2ac9cfa --- /dev/null +++ b/Sources/OpenGestures/Component/ValueTracker.swift @@ -0,0 +1,71 @@ +// +// ValueTracker.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - ValueTracker + +package struct ValueTracker: Sendable { + + package var upstream: Upstream + + package struct State: GestureComponentState, NestedCustomStringConvertible { + package var initialValue: V? + package var previousValue: V? + + package init() { + initialValue = nil + previousValue = nil + } + } + + package var state: State + + package let valueReader: @Sendable (Upstream.Value) -> V + + package init( + upstream: Upstream, + state: State = State(), + valueReader: @escaping @Sendable (Upstream.Value) -> V + ) { + self.upstream = upstream + self.state = state + self.valueReader = valueReader + } +} + +// MARK: - ValueTracker + Component Protocols + +extension ValueTracker: GestureComponent { + package typealias Value = TrackedValue +} + +extension ValueTracker: CompositeGestureComponent {} + +extension ValueTracker: StatefulGestureComponent {} + +extension ValueTracker: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool + ) throws -> GestureOutput { + let current = valueReader(value) + if state.initialValue == nil { + state.initialValue = current + } + let previous = state.previousValue ?? state.initialValue! + let trackedValue = TrackedValue( + current: current, + previous: previous, + initial: state.initialValue! + ) + state.previousValue = current + if isFinal { + return .finalValue(trackedValue, metadata: nil) + } else { + return .value(trackedValue, metadata: nil) + } + } +} diff --git a/Sources/OpenGestures/Component/ValueTransformingComponent.swift b/Sources/OpenGestures/Component/ValueTransformingComponent.swift new file mode 100644 index 0000000..b01f5a6 --- /dev/null +++ b/Sources/OpenGestures/Component/ValueTransformingComponent.swift @@ -0,0 +1,44 @@ +// +// ValueTransformingComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - ValueTransformingComponent + +package protocol ValueTransformingComponent: CompositeGestureComponent { + mutating func transform( + _ value: Upstream.Value, + isFinal: Bool + ) throws -> GestureOutput +} + +extension ValueTransformingComponent { + package mutating func update( + context: GestureComponentContext + ) throws -> GestureOutput { + let output = try upstream.tracingUpdate(context: context) + switch output { + case let .empty(reason, metadata): + return .empty(reason, metadata: metadata) + case let .value(value, _): + return try transform(value, isFinal: false) + case let .finalValue(value, _): + return try transform(value, isFinal: true) + } + } +} + +extension ValueTransformingComponent where Value == Upstream.Value { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool + ) throws -> GestureOutput { + if isFinal { + return .finalValue(value, metadata: nil) + } else { + return .value(value, metadata: nil) + } + } +} diff --git a/Sources/OpenGestures/Core/GestureOutput.swift b/Sources/OpenGestures/Core/GestureOutput.swift index fd42e11..16c8d06 100644 --- a/Sources/OpenGestures/Core/GestureOutput.swift +++ b/Sources/OpenGestures/Core/GestureOutput.swift @@ -35,6 +35,17 @@ extension GestureOutput { default: false } } + + package var metadata: GestureOutputMetadata? { + switch self { + case let .empty(_, metadata): + metadata + case let .value(_, metadata): + metadata + case let .finalValue(_, metadata): + metadata + } + } } // MARK: - GestureOutput + NestedCustomStringConvertible diff --git a/Sources/OpenGestures/Event/EventStore.swift b/Sources/OpenGestures/Event/EventStore.swift new file mode 100644 index 0000000..d4adae1 --- /dev/null +++ b/Sources/OpenGestures/Event/EventStore.swift @@ -0,0 +1,93 @@ +// +// EventStore.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - AnyEventStore + +/// Type-erased base class for per-event-type event stores. +package class AnyEventStore: @unchecked Sendable { + package init() {} + + package func accepts(_ eventType: E.Type) -> Bool { + _openGesturesBaseClassAbstractMethod() + } + + package func append(_ events: [E]) { + _openGesturesBaseClassAbstractMethod() + } + + package func removeUnboundTerminalEvents() { + _openGesturesBaseClassAbstractMethod() + } + + package func unbindAll() { + _openGesturesBaseClassAbstractMethod() + } +} + +// MARK: - EventStore + +/// Concrete per-event-type event store. +package final class EventStore: AnyEventStore, @unchecked Sendable { + package var events: [E] + package var boundEventIds: [EventID] + + package init(events: [E] = [], boundEventIds: [EventID] = []) { + self.events = events + self.boundEventIds = boundEventIds + super.init() + } + + package override func accepts(_ eventType: A.Type) -> Bool { + eventType == E.self + } + + package override func append(_ newEvents: [A]) { + removeUnboundTerminalEvents() + + for event in newEvents { + let isBound = boundEventIds.contains(event.id) + let phase = event.phase + guard isBound || phase == .began else { + continue + } + events.append(unsafeBitCast(event, to: E.self)) + } + } + + package func bindNextUnboundEvent() -> E? { + guard let event = events.first(where: eventIDIsUnbound) else { + return nil + } + boundEventIds.append(event.id) + return event + } + + private func eventIDIsUnbound(_ event: E) -> Bool { + !boundEventIds.contains(event.id) + } + + package override func removeUnboundTerminalEvents() { + for event in events { + guard event.phase == .ended || event.phase == .failed else { + continue + } + if let index = boundEventIds.firstIndex(of: event.id) { + boundEventIds.remove(at: index) + } + } + events.removeAll(keepingCapacity: false) + } + + package override func unbindAll() { + events.removeAll(keepingCapacity: false) + boundEventIds = [] + } +} + +// MARK: - EventStore + NestedCustomStringConvertible + +extension EventStore: NestedCustomStringConvertible {} diff --git a/Sources/OpenGestures/Extension/OGFGestureFunctions.swift b/Sources/OpenGestures/Extension/OGFGestureFunctions.swift index bf8791a..f8d3b33 100644 --- a/Sources/OpenGestures/Extension/OGFGestureFunctions.swift +++ b/Sources/OpenGestures/Extension/OGFGestureFunctions.swift @@ -26,16 +26,6 @@ public func ogfGestureNodeCoordinatorCreate( // TODO: Create GestureNodeCoordinatorShim preconditionFailure("") } - -@_cdecl("OGFGestureComponentControllerSetNode") -public func ogfGestureComponentControllerSetNode( - _ controller: AnyObject, - _ node: (any OGFGestureNode)? -) { - guard let _ = controller as? AnyGestureComponentController else { return } - preconditionFailure("") -} - #else public func OGFGestureNodeDefaultValue() -> Any { @@ -52,23 +42,32 @@ public func OGFGestureNodeCoordinatorCreate( ) -> any OGFGestureNodeCoordinator { _openGesturesPlatformUnimplementedFailure() } +#endif -public func OGFGestureComponentControllerSetNode( +#if canImport(ObjectiveC) +@_cdecl("OGFGestureComponentControllerSetNode") +#endif +public func ogfGestureComponentControllerSetNode( _ controller: AnyObject, _ node: (any OGFGestureNode)? ) { - _openGesturesPlatformUnimplementedFailure() -} - -public func OGFGestureFailureTypeIsTerminated(_ type: OGFGestureFailureType) -> Bool { - ogfGestureFailureTypeIsTerminated(type: type) + let controller = unsafeBitCast(controller, to: AnyGestureComponentController.self) + let newNode: AnyGestureNode? + if let node { + newNode = unsafeBitCast(node, to: AnyGestureNodeShim.self).node + } else { + newNode = nil + } + let previousNode = controller.node + controller.node = newNode + if controller.node == nil, previousNode != nil { + controller.reset() + } } -#endif - @_cdecl("OGFGestureFailureTypeIsTerminated") public func ogfGestureFailureTypeIsTerminated( - type: OGFGestureFailureType + _ type: OGFGestureFailureType ) -> Bool { switch type { case .customError, .disabled, .activationDenied, .aborted: diff --git a/Sources/OpenGestures/Extension/OGFGestureNode.swift b/Sources/OpenGestures/Extension/OGFGestureNode.swift index 2a04ff3..b8cb709 100644 --- a/Sources/OpenGestures/Extension/OGFGestureNode.swift +++ b/Sources/OpenGestures/Extension/OGFGestureNode.swift @@ -5,13 +5,17 @@ // Created by Kyle on 4/19/26. // -#if canImport(ObjectiveC) - import Foundation +#if canImport(ObjectiveC) @objc +#endif class AnyGestureNodeShim: NSObject, @unchecked Sendable { + package var node: AnyGestureNode { + _openGesturesBaseClassAbstractMethod() + } + // override var container: (any GestureNodeContainer)? { // didSet { // // TODO @@ -22,5 +26,3 @@ class AnyGestureNodeShim: NSObject, @unchecked Sendable { // _openGesturesBaseClassAbstractMethod() // } } - -#endif diff --git a/Sources/OpenGestures/Log.swift b/Sources/OpenGestures/Log.swift index 732ed3b..0f40d78 100644 --- a/Sources/OpenGestures/Log.swift +++ b/Sources/OpenGestures/Log.swift @@ -31,19 +31,17 @@ private func logPreferencesChangedCallback( // MARK: - Log package enum Log { - private static let unknownDefaultsCacheState: UInt = 0 - private static let enabledDefaultsCacheState: UInt = 1 - private static let disabledDefaultsCacheState: UInt = 2 - private static let defaultsCacheState = Atomic(unknownDefaultsCacheState) + private enum DefaultsCacheState: UInt { + case unknown = 0 + case enabled = 1 + case disabled = 2 + } + + private static let defaultsCacheState = Atomic(DefaultsCacheState.unknown.rawValue) private static let observerRegistered = Atomic(0) package static let subsystem = "org.OpenSwiftUIProject.OpenGestures" - package enum Category: String { - case nodes = "Nodes" - case components = "Components" - } - package static let hasInternalContent: Bool = { subsystem.withCString { ogf_variant_has_internal_diagnostics($0) } }() @@ -62,14 +60,14 @@ package enum Log { if isEnvironmentLoggingEnabled { return true } - switch defaultsCacheState.load(ordering: .acquiring) { - case unknownDefaultsCacheState: + switch DefaultsCacheState(rawValue: defaultsCacheState.load(ordering: .acquiring)) { + case .unknown: break - case enabledDefaultsCacheState: + case .enabled: return true - case disabledDefaultsCacheState: + case .disabled: return false - default: + case nil: preconditionFailure("Invalid logging defaults cache state") } guard let defaults = UserDefaults(suiteName: subsystem) else { @@ -77,7 +75,7 @@ package enum Log { } let isEnabled = defaults.bool(forKey: "LoggingEnabled") defaultsCacheState.store( - isEnabled ? enabledDefaultsCacheState : disabledDefaultsCacheState, + (isEnabled ? DefaultsCacheState.enabled : DefaultsCacheState.disabled).rawValue, ordering: .releasing ) registerLoggingPreferencesObserver() @@ -85,7 +83,7 @@ package enum Log { } fileprivate static func invalidateLoggingPreferencesCache() { - defaultsCacheState.store(unknownDefaultsCacheState, ordering: .releasing) + defaultsCacheState.store(DefaultsCacheState.unknown.rawValue, ordering: .releasing) } private static func registerLoggingPreferencesObserver() { @@ -112,26 +110,37 @@ package enum Log { } #if canImport(os) - package static let nodes = Logger(subsystem: subsystem, category: Category.nodes.rawValue) - package static let components = Logger(subsystem: subsystem, category: Category.components.rawValue) - package static let disabled = Logger(OSLog.disabled) + private static let _nodes = Logger(subsystem: subsystem, category: "Nodes") - package static func enabledLogger(for category: Category) -> Logger { - guard isGesturesLoggingEnabled else { - return disabled - } - switch category { - case .nodes: - return nodes - case .components: - return components - } + package static var nodes: Logger { + isGesturesLoggingEnabled ? _nodes : disabled + } + + private static let _components = Logger(subsystem: subsystem, category: "Components") + + package static var components: Logger { + isGesturesLoggingEnabled ? _components : disabled } + package static let componentUpdates = Logger(subsystem: subsystem, category: "ComponentUpdates") + + private static let disabled = Logger(OSLog.disabled) + + @inline(__always) package static func logEnqueuedPhase(_ node: AnyGestureNode) { - enabledLogger(for: .nodes).log("\(node.debugLabel, privacy: .public) enqueued phase") + nodes.log("\(node.debugLabel) enqueued phase") + } + + @inline(__always) + package static func logFailedScheduledUpdate() { + components.log("Failed to peform a scheduled update") } #else + // TODO: Add swift-log support + @inline(__always) package static func logEnqueuedPhase(_ node: AnyGestureNode) {} + + @inline(__always) + package static func logFailedScheduledUpdate() {} #endif } diff --git a/Sources/OpenGestures/Time/UpdateScheduler.swift b/Sources/OpenGestures/Time/UpdateScheduler.swift index ff0df4f..fbeccb7 100644 --- a/Sources/OpenGestures/Time/UpdateScheduler.swift +++ b/Sources/OpenGestures/Time/UpdateScheduler.swift @@ -19,6 +19,40 @@ public final class UpdateScheduler { self.timeScheduler = timeScheduler self.scheduledRequests = scheduledRequests } + + package func schedule( + _ requests: [UpdateRequest], + handler: @escaping (Set) -> Void, + cancelHandler: (() -> Void)? = nil + ) { + for request in requests { + let token = timeScheduler.schedule( + after: request.targetTime - request.creationTime, + handler: { handler([request.id]) }, + cancelHandler: cancelHandler + ) + scheduledRequests[request] = token + } + } + + package func cancel(_ requests: [UpdateRequest]) { + for request in requests { + guard let token = scheduledRequests.removeValue(forKey: request) else { + continue + } + timeScheduler.cancel(token: token) + } + } + + package func cancelAll() { + let requests = scheduledRequests.keys + for request in requests { + guard let token = scheduledRequests.removeValue(forKey: request) else { + continue + } + timeScheduler.cancel(token: token) + } + } } @_spi(Private) @@ -48,6 +82,14 @@ package struct UpdateRequest: Hashable, Identifiable, CustomStringConvertible { self.tag = tag } + package static func == (lhs: UpdateRequest, rhs: UpdateRequest) -> Bool { + lhs.id == rhs.id + } + + package func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + package var description: String { let duration = targetTime - creationTime var result = "{ \(id)" diff --git a/Tests/OpenGesturesTests/Component/CompositeGestureComponentTests.swift b/Tests/OpenGesturesTests/Component/CompositeGestureComponentTests.swift new file mode 100644 index 0000000..643d210 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/CompositeGestureComponentTests.swift @@ -0,0 +1,67 @@ +// +// CompositeGestureComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - CompositeGestureComponentTests + +@Suite +struct CompositeGestureComponentTests { + @Test + func statefulCompositeResetResetsUpstreamAndState() { + var component = StatefulCompositeStub( + upstream: ResettableStub(), + state: StatefulCompositeStub.State(value: 42) + ) + + component.reset() + + #expect(component.upstream.resetCount == 1) + #expect(component.state.value == 0) + } +} + +private struct StatefulCompositeStub: CompositeGestureComponent, StatefulGestureComponent { + var upstream: ResettableStub + var state: State + + func update(context: GestureComponentContext) throws -> GestureOutput { + .empty(.noData, metadata: nil) + } + + struct State: GestureComponentState { + var value: Int + + init() { + value = 0 + } + + init(value: Int) { + self.value = value + } + } +} + +private struct ResettableStub: GestureComponent { + var resetCount = 0 + + func update(context: GestureComponentContext) throws -> GestureOutput { + .empty(.noData, metadata: nil) + } + + mutating func reset() { + resetCount += 1 + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/MovementGateTests.swift b/Tests/OpenGesturesTests/Component/MovementGateTests.swift new file mode 100644 index 0000000..38d564a --- /dev/null +++ b/Tests/OpenGesturesTests/Component/MovementGateTests.swift @@ -0,0 +1,110 @@ +// +// MovementGateTests.swift +// OpenGesturesTests +// +// Generated + +import OpenCoreGraphicsShims +import OpenGestures +import Testing + +// MARK: - MovementGateTests + +@Suite +struct MovementGateTests { + @Test + func minRestrictionFiltersUntilMovementReachesBound() throws { + var component = MovementGate( + upstream: TrackedStubComponent(outputs: [ + .value(tracked(current: CGPoint(x: 3, y: 4)), metadata: nil), + .finalValue(tracked(current: CGPoint(x: 6, y: 8)), metadata: nil), + ]), + bound: 6, + restriction: .min + ) + + let filteredOutput = try component.update(context: makeMovementGateContext()) + let finalOutput = try component.update(context: makeMovementGateContext()) + + guard case let .empty(reason, filteredMetadata) = filteredOutput else { + Issue.record("Expected filtered output") + return + } + #expect(reason == .filtered) + #expect(filteredMetadata?.traceAnnotation?.value == "not enough movement") + + guard case let .finalValue(finalValue, finalMetadata) = finalOutput else { + Issue.record("Expected final value output") + return + } + #expect(finalValue.current == CGPoint(x: 6, y: 8)) + #expect(finalMetadata == nil) + } + + @Test + func maxRestrictionThrowsWhenMovementExceedsBound() throws { + typealias Gate = MovementGate + + var component = Gate( + upstream: TrackedStubComponent(outputs: [ + .value(tracked(current: CGPoint(x: 3, y: 4)), metadata: nil), + .value(tracked(current: CGPoint(x: 6, y: 0)), metadata: nil), + ]), + bound: 5, + restriction: .max + ) + + let valueOutput = try component.update(context: makeMovementGateContext()) + guard case let .value(value, valueMetadata) = valueOutput else { + Issue.record("Expected value output") + return + } + #expect(value.current == CGPoint(x: 3, y: 4)) + #expect(valueMetadata == nil) + + do { + _ = try component.update(context: makeMovementGateContext()) + Issue.record("Expected tooMuchMovement") + } catch Gate.Failure.tooMuchMovement { + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} + +private func tracked( + current: CGPoint, + initial: CGPoint = .zero, + previous: CGPoint? = nil +) -> TrackedValue { + TrackedValue(current: current, previous: previous, initial: initial) +} + +private func makeMovementGateContext() -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct TrackedStubComponent: 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/UpdateTracerTests.swift b/Tests/OpenGesturesTests/Component/UpdateTracerTests.swift new file mode 100644 index 0000000..203126a --- /dev/null +++ b/Tests/OpenGesturesTests/Component/UpdateTracerTests.swift @@ -0,0 +1,111 @@ +// +// UpdateTracerTests.swift +// OpenGesturesTests + +import OpenGestures +import Testing + +// MARK: - TraceRenderingTests + +@Suite +struct TraceRenderingTests { + @Test(arguments: [ + ( + Trace(id: 1), + [ + Int16(1): traceDataSnapshot( + component: "RootComponent", + state: "State\nroot state", + result: "root result\nsecond result" + ), + ], + "P: ", + "| ", + #""" + P: RootComponent + | root state + | Output: root result + | second result + """# + "\n" + ), + ( + Trace( + id: 10, + upstreamTraces: [ + Trace(id: 11), + Trace( + id: 12, + upstreamTraces: [ + Trace(id: 13), + ] + ), + ] + ), + [ + Int16(10): traceDataSnapshot( + component: "Root", + state: "State\nroot state", + result: "root result" + ), + Int16(11): traceDataSnapshot( + component: "FirstChild", + state: "State\nchild state", + result: "child error\nsecond line", + isSuccess: false + ), + Int16(13): traceDataSnapshot( + component: "GrandChild" + ), + ], + "", + "", + #""" + Root + root state + Output: root result + ↑ + │ + ├── FirstChild + │ child state + │ Error: child error + │ second line + │ + └── UnknownComponent + + ↑ + │ + └── GrandChild + + """# + "\n" + ), + ]) + func rendered( + _ trace: Trace, + _ snapshots: [Int16: TraceDataSnapshot], + _ prefix: String, + _ childPrefix: String, + _ expectedString: String + ) { + #expect( + trace.rendered( + using: snapshots, + prefix: prefix, + childPrefix: childPrefix + ) == expectedString + ) + } +} + +private func traceDataSnapshot( + component: String, + state: String = "", + result: String = "", + isSuccess: Bool = true +) -> TraceDataSnapshot { + TraceDataSnapshot( + component: { component }, + result: { result }, + state: { state }, + isSuccess: isSuccess + ) +} diff --git a/Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift b/Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift new file mode 100644 index 0000000..0904f63 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift @@ -0,0 +1,137 @@ +// +// ValueTransformingComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - ValueTransformingComponentTests + +@Suite +struct ValueTransformingComponentTests { + @Test + func defaultUpdatePreservesEmptyOutput() throws { + let metadata = GestureOutputMetadata(traceAnnotation: UpdateTraceAnnotation(value: "empty")) + var component = DiscreteGate( + upstream: StubComponent(outputs: [ + .empty(.filtered, metadata: metadata), + ]) + ) + + let output = try component.update(context: makeContext()) + + guard case let .empty(reason, outputMetadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .filtered) + #expect(outputMetadata?.traceAnnotation?.value == "empty") + } + + @Test + func discreteGateFiltersValueAndPassesFinalValue() throws { + var component = DiscreteGate( + upstream: StubComponent(outputs: [ + .value( + 3, + metadata: GestureOutputMetadata(traceAnnotation: UpdateTraceAnnotation(value: "value")) + ), + .finalValue( + 5, + metadata: GestureOutputMetadata(traceAnnotation: UpdateTraceAnnotation(value: "final")) + ), + ]) + ) + + let valueOutput = try component.update(context: makeContext()) + let finalOutput = try component.update(context: makeContext()) + + guard case let .empty(reason, valueMetadata) = valueOutput else { + Issue.record("Expected filtered empty output") + return + } + #expect(reason == .filtered) + #expect(valueMetadata?.traceAnnotation?.value == "not final event") + + guard case let .finalValue(finalValue, finalMetadata) = finalOutput else { + Issue.record("Expected final value output") + return + } + #expect(finalValue == 5) + #expect(finalMetadata == nil) + } + + @Test + func valueTrackerTransformsUpstreamValues() throws { + var component = ValueTracker( + upstream: StubComponent(outputs: [ + .value(2, metadata: nil), + .value(5, metadata: nil), + .finalValue(7, metadata: nil), + ]), + valueReader: { $0 * 10 } + ) + + let firstOutput = try component.update(context: makeContext()) + let secondOutput = try component.update(context: makeContext()) + let finalOutput = try component.update(context: makeContext()) + + guard case let .value(first, firstMetadata) = firstOutput else { + Issue.record("Expected first value output") + return + } + #expect(first.current == 20) + #expect(first.previous == 20) + #expect(first.initial == 20) + #expect(firstMetadata == nil) + + guard case let .value(second, secondMetadata) = secondOutput else { + Issue.record("Expected second value output") + return + } + #expect(second.current == 50) + #expect(second.previous == 20) + #expect(second.initial == 20) + #expect(secondMetadata == nil) + + guard case let .finalValue(final, finalMetadata) = finalOutput else { + Issue.record("Expected final value output") + return + } + #expect(final.current == 70) + #expect(final.previous == 50) + #expect(final.initial == 20) + #expect(finalMetadata == nil) + } +} + +private func makeContext() -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct StubComponent: 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/Event/EventStoreTests.swift b/Tests/OpenGesturesTests/Event/EventStoreTests.swift new file mode 100644 index 0000000..36b7311 --- /dev/null +++ b/Tests/OpenGesturesTests/Event/EventStoreTests.swift @@ -0,0 +1,151 @@ +// +// EventStoreTests.swift +// OpenGesturesTests + +@_spi(Private) import OpenGestures +import Testing + +// MARK: - EventStoreTests + +@Suite +struct EventStoreTests { + @Test + func appendKeepsBoundEventsAndUnboundBeganEvents() { + let store = EventStore( + events: [ + TestEvent(id: 4, phase: .ended), + ], + boundEventIds: [ + EventID(rawValue: 2), + EventID(rawValue: 4), + ] + ) + + store.append([ + TestEvent(id: 1, phase: .active), + TestEvent(id: 2, phase: .active), + TestEvent(id: 3, phase: .began), + TestEvent(id: 4, phase: .failed), + TestEvent(id: 5, phase: .ended), + ]) + + #expect(store.events.map(\.id) == [EventID(rawValue: 2), EventID(rawValue: 3)]) + #expect(store.boundEventIds == [EventID(rawValue: 2)]) + } + + @Test + func appendReadsPhaseEvenWhenEventIsAlreadyBound() { + let tracker = PhaseReadTracker() + let store = EventStore( + boundEventIds: [ + EventID(rawValue: 1), + ] + ) + + store.append([ + PhaseTrackingEvent(id: 1, phase: .active, tracker: tracker), + ]) + + #expect(tracker.readCount == 1) + #expect(store.events.map(\.id) == [EventID(rawValue: 1)]) + } + + @Test + func bindNextUnboundEventReturnsFirstUnboundEventAndBindsIt() { + let store = EventStore( + events: [ + TestEvent(id: 1, phase: .active), + TestEvent(id: 2, phase: .active), + TestEvent(id: 3, phase: .active), + ], + boundEventIds: [ + EventID(rawValue: 1), + ] + ) + + let event = store.bindNextUnboundEvent() + + #expect(event?.id == EventID(rawValue: 2)) + #expect(store.boundEventIds == [EventID(rawValue: 1), EventID(rawValue: 2)]) + } + + @Test + func bindNextUnboundEventReturnsNilWhenAllEventsAreBound() { + let store = EventStore( + events: [ + TestEvent(id: 1, phase: .active), + TestEvent(id: 2, phase: .active), + ], + boundEventIds: [ + EventID(rawValue: 1), + EventID(rawValue: 2), + ] + ) + + let event = store.bindNextUnboundEvent() + + #expect(event == nil) + #expect(store.boundEventIds == [EventID(rawValue: 1), EventID(rawValue: 2)]) + } + + @Test + func removeUnboundTerminalEventsClearsEventsAndUnbindsTerminalIds() { + let store = EventStore( + events: [ + TestEvent(id: 1, phase: .ended), + TestEvent(id: 2, phase: .ended), + TestEvent(id: 3, phase: .failed), + TestEvent(id: 4, phase: .active), + ], + boundEventIds: [ + EventID(rawValue: 2), + EventID(rawValue: 3), + EventID(rawValue: 4), + ] + ) + + store.removeUnboundTerminalEvents() + + #expect(store.events.isEmpty) + #expect(store.boundEventIds == [EventID(rawValue: 4)]) + } +} + +// MARK: - TestEvent + +private struct TestEvent: Event { + let id: EventID + let phase: EventPhase + let timestamp: Timestamp + + init(id rawValue: Int, phase: EventPhase) { + self.id = EventID(rawValue: rawValue) + self.phase = phase + timestamp = Timestamp(value: .zero) + } +} + +// MARK: - PhaseTrackingEvent + +private final class PhaseReadTracker { + var readCount = 0 +} + +private struct PhaseTrackingEvent: Event { + let id: EventID + private let storedPhase: EventPhase + private let tracker: PhaseReadTracker + let timestamp: Timestamp + + init(id rawValue: Int, phase: EventPhase, tracker: PhaseReadTracker) { + self.id = EventID(rawValue: rawValue) + storedPhase = phase + self.tracker = tracker + timestamp = Timestamp(value: .zero) + } + + var phase: EventPhase { + tracker.readCount += 1 + return storedPhase + } +}