From ea58585038637a5ee81a71d578af6a8256a6165c Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 2 May 2026 17:17:03 +0800 Subject: [PATCH 01/18] Add EventStore --- Sources/OpenGestures/Event/EventStore.swift | 95 +++++++++++ .../Event/EventStoreTests.swift | 151 ++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 Sources/OpenGestures/Event/EventStore.swift create mode 100644 Tests/OpenGesturesTests/Event/EventStoreTests.swift diff --git a/Sources/OpenGestures/Event/EventStore.swift b/Sources/OpenGestures/Event/EventStore.swift new file mode 100644 index 0000000..e7b39b3 --- /dev/null +++ b/Sources/OpenGestures/Event/EventStore.swift @@ -0,0 +1,95 @@ +// +// 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 + } + // The reference binary relies on the caller routing matching event + // stores and uses a size-checked cast before appending to [E]. + 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/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 + } +} From a3b418f35200c2d53c8614fdb56f599e62f87f4c Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 2 May 2026 23:11:36 +0800 Subject: [PATCH 02/18] Add Tracker --- .../OpenGestures/Component/TrackedValue.swift | 37 +++++++++++ .../OpenGestures/Component/UpdateTracer.swift | 63 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 Sources/OpenGestures/Component/TrackedValue.swift create mode 100644 Sources/OpenGestures/Component/UpdateTracer.swift diff --git a/Sources/OpenGestures/Component/TrackedValue.swift b/Sources/OpenGestures/Component/TrackedValue.swift new file mode 100644 index 0000000..198452c --- /dev/null +++ b/Sources/OpenGestures/Component/TrackedValue.swift @@ -0,0 +1,37 @@ +// +// TrackedValue.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// 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 + } +} + +// 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..02f6a7a --- /dev/null +++ b/Sources/OpenGestures/Component/UpdateTracer.swift @@ -0,0 +1,63 @@ +// +// UpdateTracer.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - UpdateTracer + +package class UpdateTracer: @unchecked Sendable { + package var seed: Int16 + package var traceHead: Trace? + package var activeTraces: [Trace] + package var pendingBranches: [Int16: [Trace]] + package var dataSnapshots: [Int16: TraceDataSnapshot] + + 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 + } +} + +// MARK: - TraceDataSnapshot + +package struct TraceDataSnapshot: Sendable { + package var component: @Sendable () -> String + package var result: @Sendable () -> String + package var state: @Sendable () -> String + package var isSuccess: Bool + + 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 + +package struct Trace: Identifiable, Sendable { + package var id: Int16 + package var upstreamTraces: [Trace] + + package init(id: Int16, upstreamTraces: [Trace] = []) { + self.id = id + self.upstreamTraces = upstreamTraces + } +} From d62c4c57e1d7926bc424596cd468439f8e0b0beb Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 3 May 2026 00:02:32 +0800 Subject: [PATCH 03/18] Add GestureComponentState --- .../Component/GestureComponentState.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 Sources/OpenGestures/Component/GestureComponentState.swift diff --git a/Sources/OpenGestures/Component/GestureComponentState.swift b/Sources/OpenGestures/Component/GestureComponentState.swift new file mode 100644 index 0000000..ee94224 --- /dev/null +++ b/Sources/OpenGestures/Component/GestureComponentState.swift @@ -0,0 +1,26 @@ +// +// 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() + } +} + +// MARK: - GestureComponentState + +public protocol GestureComponentState: Sendable { + init() +} From 5fd04eb034ae5c9e61d116486b459a5e33543127 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 3 May 2026 00:22:01 +0800 Subject: [PATCH 04/18] Update GestureComponent --- .../Component/GestureComponent.swift | 72 ++++++------------- 1 file changed, 23 insertions(+), 49 deletions(-) diff --git a/Sources/OpenGestures/Component/GestureComponent.swift b/Sources/OpenGestures/Component/GestureComponent.swift index 2a9c0e7..3d03160 100644 --- a/Sources/OpenGestures/Component/GestureComponent.swift +++ b/Sources/OpenGestures/Component/GestureComponent.swift @@ -1,77 +1,51 @@ +// +// 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() - } -} - -// 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:)") - } - - public mutating func reset() { - upstream.reset() - } - - public func traits() -> GestureTraitCollection? { - upstream.traits() + package mutating func tracingUpdate(context: GestureComponentContext) throws -> GestureOutput { + try update(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) { - self.startTime = startTime - self.currentTime = currentTime - } } -// 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 } From 2ef5e9ebcf909af7e0dc854d027ab33de1470c26 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 3 May 2026 11:52:39 +0800 Subject: [PATCH 05/18] Update GestureComponent --- .../Component/GestureComponent.swift | 50 ++++++++++++++++++- .../OpenGestures/Component/UpdateTracer.swift | 23 ++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/Sources/OpenGestures/Component/GestureComponent.swift b/Sources/OpenGestures/Component/GestureComponent.swift index 3d03160..9c3395d 100644 --- a/Sources/OpenGestures/Component/GestureComponent.swift +++ b/Sources/OpenGestures/Component/GestureComponent.swift @@ -20,7 +20,41 @@ public protocol GestureComponent: Sendable { extension GestureComponent { package mutating func tracingUpdate(context: GestureComponentContext) throws -> GestureOutput { - try update(context: context) + 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() + } + + 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 + } + }() + ) } } @@ -37,6 +71,20 @@ public struct GestureComponentContext: @unchecked Sendable { public var durationSinceStart: Duration { startTime.duration(to: currentTime) } + + 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: - GestureUpdateSource diff --git a/Sources/OpenGestures/Component/UpdateTracer.swift b/Sources/OpenGestures/Component/UpdateTracer.swift index 02f6a7a..77d4c52 100644 --- a/Sources/OpenGestures/Component/UpdateTracer.swift +++ b/Sources/OpenGestures/Component/UpdateTracer.swift @@ -27,6 +27,27 @@ package class UpdateTracer: @unchecked Sendable { self.pendingBranches = pendingBranches self.dataSnapshots = dataSnapshots } + + 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)) + } + + 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 + } } // MARK: - TraceDataSnapshot @@ -52,7 +73,7 @@ package struct TraceDataSnapshot: Sendable { // MARK: - Trace -package struct Trace: Identifiable, Sendable { +package struct Trace: Hashable, Identifiable, Sendable { package var id: Int16 package var upstreamTraces: [Trace] From 032d9b8820ac0e89081615e37923b54e1db69c10 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 3 May 2026 17:10:50 +0800 Subject: [PATCH 06/18] Add CompositeGestureComponent --- .../Component/CompositeGestureComponent.swift | 28 +++++++++++++++++++ .../Component/GestureComponent.swift | 8 ++++++ 2 files changed, 36 insertions(+) create mode 100644 Sources/OpenGestures/Component/CompositeGestureComponent.swift 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/GestureComponent.swift b/Sources/OpenGestures/Component/GestureComponent.swift index 9c3395d..58bbd61 100644 --- a/Sources/OpenGestures/Component/GestureComponent.swift +++ b/Sources/OpenGestures/Component/GestureComponent.swift @@ -58,6 +58,14 @@ extension GestureComponent { } } +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. From 6f1ee92265e8cec371576bf99b80e1e1f197b83c Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 3 May 2026 17:41:36 +0800 Subject: [PATCH 07/18] Add DiscreteComponent --- Sources/OpenGestures/Component/DiscreteComponent.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 Sources/OpenGestures/Component/DiscreteComponent.swift 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 {} From ed175db6209933b701f8df3ce863654175755967 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 3 May 2026 19:23:26 +0800 Subject: [PATCH 08/18] Add ValueTracker --- .../Component/GestureComponentState.swift | 7 ++ .../OpenGestures/Component/ValueTracker.swift | 71 +++++++++++++++++++ .../ValueTransformingComponent.swift | 44 ++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 Sources/OpenGestures/Component/ValueTracker.swift create mode 100644 Sources/OpenGestures/Component/ValueTransformingComponent.swift diff --git a/Sources/OpenGestures/Component/GestureComponentState.swift b/Sources/OpenGestures/Component/GestureComponentState.swift index ee94224..a9eaeb7 100644 --- a/Sources/OpenGestures/Component/GestureComponentState.swift +++ b/Sources/OpenGestures/Component/GestureComponentState.swift @@ -19,6 +19,13 @@ extension StatefulGestureComponent { } } +extension CompositeGestureComponent where Self: StatefulGestureComponent { + public mutating func reset() { + upstream.reset() + state = State() + } +} + // MARK: - GestureComponentState public protocol GestureComponentState: Sendable { 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) + } + } +} From cd1d3c66684f2c44382b03e2b809f0e38aaa73fb Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 4 May 2026 14:53:55 +0800 Subject: [PATCH 09/18] Add DiscreteGate --- .../OpenGestures/Component/DiscreteGate.swift | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 Sources/OpenGestures/Component/DiscreteGate.swift 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") + ) + ) + } + } +} From 61c72b7aedb49c0737f0b2ce162977b6fbe0c5b8 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 4 May 2026 15:18:27 +0800 Subject: [PATCH 10/18] Add test case --- .../CompositeGestureComponentTests.swift | 67 +++++++++ .../Component/MovementGateTests.swift | 109 ++++++++++++++ .../ValueTransformingComponentTests.swift | 137 ++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 Tests/OpenGesturesTests/Component/CompositeGestureComponentTests.swift create mode 100644 Tests/OpenGesturesTests/Component/MovementGateTests.swift create mode 100644 Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift 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..abec22e --- /dev/null +++ b/Tests/OpenGesturesTests/Component/MovementGateTests.swift @@ -0,0 +1,109 @@ +// +// MovementGateTests.swift +// OpenGesturesTests +// +// Generated + +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/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 + } +} From 5d12b33c492c22e9fc0802153859f83d83f76ef3 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 4 May 2026 15:19:23 +0800 Subject: [PATCH 11/18] Disable Component code --- .../Component/LongPressComponent.swift | 79 +++++----- .../OpenGestures/Component/PanComponent.swift | 147 ++++++++++-------- .../OpenGestures/Component/TapComponent.swift | 75 +++++---- 3 files changed, 170 insertions(+), 131 deletions(-) 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/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() +// } +//} From f52536245cd2d55db742c2bcc19835e8fb033e60 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 4 May 2026 15:38:36 +0800 Subject: [PATCH 12/18] Add MovementGate --- .../OpenGestures/Component/MovementGate.swift | 78 +++++++++++++++++++ .../OpenGestures/Component/TrackedValue.swift | 4 + 2 files changed, 82 insertions(+) create mode 100644 Sources/OpenGestures/Component/MovementGate.swift 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/TrackedValue.swift b/Sources/OpenGestures/Component/TrackedValue.swift index 198452c..7c35680 100644 --- a/Sources/OpenGestures/Component/TrackedValue.swift +++ b/Sources/OpenGestures/Component/TrackedValue.swift @@ -30,6 +30,10 @@ extension TrackedValue: LocationContaining where Value: LocationContaining { package var location: CGPoint { current.location } + + package var locationTranslation: CGPoint { + current.location - initial.location + } } // MARK: - TrackedValue + Equatable From 145a9fb4e1ad245b110b29d15dfb595af585c381 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 4 May 2026 16:42:16 +0800 Subject: [PATCH 13/18] Update OGFGestureFunctions --- .../Extension/OGFGestureFunctions.swift | 36 +++++++++---------- .../Extension/OGFGestureNode.swift | 4 +++ 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/Sources/OpenGestures/Extension/OGFGestureFunctions.swift b/Sources/OpenGestures/Extension/OGFGestureFunctions.swift index bf8791a..e520c9f 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,31 @@ public func OGFGestureNodeCoordinatorCreate( ) -> any OGFGestureNodeCoordinator { _openGesturesPlatformUnimplementedFailure() } +#endif -public func OGFGestureComponentControllerSetNode( +@_cdecl("OGFGestureComponentControllerSetNode") +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 shim = unsafeBitCast(node, to: AnyGestureNodeShim.self) + let newNode: AnyGestureNode? + if let node { + newNode = shim.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..e5166f9 100644 --- a/Sources/OpenGestures/Extension/OGFGestureNode.swift +++ b/Sources/OpenGestures/Extension/OGFGestureNode.swift @@ -12,6 +12,10 @@ import Foundation @objc class AnyGestureNodeShim: NSObject, @unchecked Sendable { + package var node: AnyGestureNode { + _openGesturesBaseClassAbstractMethod() + } + // override var container: (any GestureNodeContainer)? { // didSet { // // TODO From 2d5748d86700a89bff65b71039f6b489c8ec4f1e Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 4 May 2026 18:39:59 +0800 Subject: [PATCH 14/18] Add ComponentController --- .../Component/GestureComponent.swift | 8 + .../GestureComponentController.swift | 155 +++++++++---- .../OpenGestures/Component/UpdateTracer.swift | 212 ++++++++++++++++++ Sources/OpenGestures/Core/GestureOutput.swift | 11 + Sources/OpenGestures/Event/EventStore.swift | 2 - Sources/OpenGestures/Log.swift | 5 +- .../OpenGestures/Time/UpdateScheduler.swift | 37 +++ .../Component/UpdateTracerTests.swift | 111 +++++++++ 8 files changed, 499 insertions(+), 42 deletions(-) create mode 100644 Tests/OpenGesturesTests/Component/UpdateTracerTests.swift diff --git a/Sources/OpenGestures/Component/GestureComponent.swift b/Sources/OpenGestures/Component/GestureComponent.swift index 58bbd61..d893e61 100644 --- a/Sources/OpenGestures/Component/GestureComponent.swift +++ b/Sources/OpenGestures/Component/GestureComponent.swift @@ -19,6 +19,14 @@ public protocol GestureComponent: Sendable { // MARK: - GestureComponent + Tracing extension GestureComponent { + package mutating func tracingUpdateResult( + context: GestureComponentContext + ) -> Result, any Error> { + Result { + try tracingUpdate(context: context) + } + } + package mutating func tracingUpdate(context: GestureComponentContext) throws -> GestureOutput { guard let updateTracer = context.updateTracer else { return try update(context: context) diff --git a/Sources/OpenGestures/Component/GestureComponentController.swift b/Sources/OpenGestures/Component/GestureComponentController.swift index ab10aeb..2ea2f86 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,129 @@ 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 processMetadata(output, eventType: eventType) + } + if let node { + try dispatch(node, result: result) + } + updateListener?(result) + } + + private func processMetadata( + _ output: GestureOutput, + eventType: E.Type + ) throws { + guard let metadata = output.metadata else { + return + } + if !metadata.updatesToSchedule.isEmpty { + updateScheduler.schedule(metadata.updatesToSchedule) { [weak self] requestIDs in + guard let self else { + return + } + do { + try self.performUpdate( + updateSource: .scheduler(requestIDs), + eventType: eventType + ) + } catch { + // Scheduled update failures are consumed because scheduler + // callbacks cannot throw. + } + } + } + if !metadata.updatesToCancel.isEmpty { + updateScheduler.cancel(metadata.updatesToCancel) + } + } + + 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/UpdateTracer.swift b/Sources/OpenGestures/Component/UpdateTracer.swift index 77d4c52..aad0fd0 100644 --- a/Sources/OpenGestures/Component/UpdateTracer.swift +++ b/Sources/OpenGestures/Component/UpdateTracer.swift @@ -5,15 +5,42 @@ // 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, @@ -28,6 +55,10 @@ package class UpdateTracer: @unchecked Sendable { 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 { @@ -37,6 +68,13 @@ package class UpdateTracer: @unchecked Sendable { 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 @@ -48,16 +86,62 @@ package class UpdateTracer: @unchecked Sendable { } 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, @@ -73,12 +157,140 @@ package struct TraceDataSnapshot: Sendable { // 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/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 index e7b39b3..d4adae1 100644 --- a/Sources/OpenGestures/Event/EventStore.swift +++ b/Sources/OpenGestures/Event/EventStore.swift @@ -54,8 +54,6 @@ package final class EventStore: AnyEventStore, @unchecked Sendable { guard isBound || phase == .began else { continue } - // The reference binary relies on the caller routing matching event - // stores and uses a size-checked cast before appending to [E]. events.append(unsafeBitCast(event, to: E.self)) } } diff --git a/Sources/OpenGestures/Log.swift b/Sources/OpenGestures/Log.swift index 732ed3b..74270c2 100644 --- a/Sources/OpenGestures/Log.swift +++ b/Sources/OpenGestures/Log.swift @@ -114,6 +114,7 @@ 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 componentUpdates = Logger(subsystem: subsystem, category: "ComponentUpdates") package static let disabled = Logger(OSLog.disabled) package static func enabledLogger(for category: Category) -> Logger { @@ -128,10 +129,12 @@ package enum Log { } } + @inline(__always) package static func logEnqueuedPhase(_ node: AnyGestureNode) { - enabledLogger(for: .nodes).log("\(node.debugLabel, privacy: .public) enqueued phase") + enabledLogger(for: .nodes).log("\(node.debugLabel) enqueued phase") } #else + @inline(__always) package static func logEnqueuedPhase(_ node: AnyGestureNode) {} #endif } diff --git a/Sources/OpenGestures/Time/UpdateScheduler.swift b/Sources/OpenGestures/Time/UpdateScheduler.swift index ff0df4f..c261ed6 100644 --- a/Sources/OpenGestures/Time/UpdateScheduler.swift +++ b/Sources/OpenGestures/Time/UpdateScheduler.swift @@ -19,6 +19,43 @@ public final class UpdateScheduler { self.timeScheduler = timeScheduler self.scheduledRequests = scheduledRequests } + + // TBA + package func schedule( + _ requests: [UpdateRequest], + handler: @escaping (Set) -> Void + ) { + for request in requests { + if let token = scheduledRequests[request] { + timeScheduler.cancel(token: token) + } + let token = timeScheduler.schedule( + after: request.targetTime - timestamp, + handler: { handler([request.id]) }, + cancelHandler: nil + ) + 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) 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 + ) +} From b1e28b457629a06e79473d7a2634ef30d84afdb9 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 5 May 2026 16:41:32 +0800 Subject: [PATCH 15/18] Add performScheduledUpdate --- .../GestureComponentController.swift | 24 ++++++++++++------- Sources/OpenGestures/Log.swift | 8 +++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/Sources/OpenGestures/Component/GestureComponentController.swift b/Sources/OpenGestures/Component/GestureComponentController.swift index 2ea2f86..b0125bd 100644 --- a/Sources/OpenGestures/Component/GestureComponentController.swift +++ b/Sources/OpenGestures/Component/GestureComponentController.swift @@ -118,15 +118,7 @@ public final class GestureComponentController: AnyG guard let self else { return } - do { - try self.performUpdate( - updateSource: .scheduler(requestIDs), - eventType: eventType - ) - } catch { - // Scheduled update failures are consumed because scheduler - // callbacks cannot throw. - } + performScheduledUpdate(requestIDs, eventType: eventType) } } if !metadata.updatesToCancel.isEmpty { @@ -134,6 +126,20 @@ public final class GestureComponentController: AnyG } } + 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> diff --git a/Sources/OpenGestures/Log.swift b/Sources/OpenGestures/Log.swift index 74270c2..d45ced5 100644 --- a/Sources/OpenGestures/Log.swift +++ b/Sources/OpenGestures/Log.swift @@ -133,8 +133,16 @@ package enum Log { package static func logEnqueuedPhase(_ node: AnyGestureNode) { enabledLogger(for: .nodes).log("\(node.debugLabel) enqueued phase") } + + @inline(__always) + package static func logFailedScheduledUpdate() { + enabledLogger(for: .components).log("Failed to peform a scheduled update") + } #else @inline(__always) package static func logEnqueuedPhase(_ node: AnyGestureNode) {} + + @inline(__always) + package static func logFailedScheduledUpdate() {} #endif } From 515644f101dfd232f3f52a2b161b16663b99b7eb Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 5 May 2026 16:48:02 +0800 Subject: [PATCH 16/18] Update Log API --- Sources/OpenGestures/Log.swift | 62 ++++++++++++++++------------------ 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/Sources/OpenGestures/Log.swift b/Sources/OpenGestures/Log.swift index d45ced5..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,33 +110,33 @@ 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 componentUpdates = Logger(subsystem: subsystem, category: "ComponentUpdates") - 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) enqueued phase") + nodes.log("\(node.debugLabel) enqueued phase") } @inline(__always) package static func logFailedScheduledUpdate() { - enabledLogger(for: .components).log("Failed to peform a scheduled update") + components.log("Failed to peform a scheduled update") } #else + // TODO: Add swift-log support @inline(__always) package static func logEnqueuedPhase(_ node: AnyGestureNode) {} From 660011c0391230980d919927eab6ebe82e7517ff Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 5 May 2026 16:55:59 +0800 Subject: [PATCH 17/18] Update applyOutputMetadata --- .../GestureComponentController.swift | 20 +++++++++++-------- .../OpenGestures/Time/UpdateScheduler.swift | 19 +++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/Sources/OpenGestures/Component/GestureComponentController.swift b/Sources/OpenGestures/Component/GestureComponentController.swift index b0125bd..59db93b 100644 --- a/Sources/OpenGestures/Component/GestureComponentController.swift +++ b/Sources/OpenGestures/Component/GestureComponentController.swift @@ -98,7 +98,7 @@ public final class GestureComponentController: AnyG updateTracer.reset() } if let output = try? result.get() { - try processMetadata(output, eventType: eventType) + try applyOutputMetadata(output, eventType: eventType) } if let node { try dispatch(node, result: result) @@ -106,7 +106,7 @@ public final class GestureComponentController: AnyG updateListener?(result) } - private func processMetadata( + private func applyOutputMetadata( _ output: GestureOutput, eventType: E.Type ) throws { @@ -114,12 +114,16 @@ public final class GestureComponentController: AnyG return } if !metadata.updatesToSchedule.isEmpty { - updateScheduler.schedule(metadata.updatesToSchedule) { [weak self] requestIDs in - guard let self else { - return - } - performScheduledUpdate(requestIDs, eventType: eventType) - } + 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) diff --git a/Sources/OpenGestures/Time/UpdateScheduler.swift b/Sources/OpenGestures/Time/UpdateScheduler.swift index c261ed6..fbeccb7 100644 --- a/Sources/OpenGestures/Time/UpdateScheduler.swift +++ b/Sources/OpenGestures/Time/UpdateScheduler.swift @@ -20,19 +20,16 @@ public final class UpdateScheduler { self.scheduledRequests = scheduledRequests } - // TBA package func schedule( _ requests: [UpdateRequest], - handler: @escaping (Set) -> Void + handler: @escaping (Set) -> Void, + cancelHandler: (() -> Void)? = nil ) { for request in requests { - if let token = scheduledRequests[request] { - timeScheduler.cancel(token: token) - } let token = timeScheduler.schedule( - after: request.targetTime - timestamp, + after: request.targetTime - request.creationTime, handler: { handler([request.id]) }, - cancelHandler: nil + cancelHandler: cancelHandler ) scheduledRequests[request] = token } @@ -85,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)" From eb9811584861d42737cc9171b30d1627d64f8656 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 5 May 2026 17:30:25 +0800 Subject: [PATCH 18/18] Fix Linux build issue --- Sources/OpenGestures/Component/TrackedValue.swift | 4 ++++ Sources/OpenGestures/Extension/OGFGestureFunctions.swift | 5 +++-- Sources/OpenGestures/Extension/OGFGestureNode.swift | 6 ++---- Tests/OpenGesturesTests/Component/MovementGateTests.swift | 1 + 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Sources/OpenGestures/Component/TrackedValue.swift b/Sources/OpenGestures/Component/TrackedValue.swift index 7c35680..4d10db1 100644 --- a/Sources/OpenGestures/Component/TrackedValue.swift +++ b/Sources/OpenGestures/Component/TrackedValue.swift @@ -5,6 +5,10 @@ // Audited for 9126.1.5 // Status: Complete +#if !canImport(Darwin) +package import OpenCoreGraphicsShims +#endif + // MARK: - TrackedValue @frozen diff --git a/Sources/OpenGestures/Extension/OGFGestureFunctions.swift b/Sources/OpenGestures/Extension/OGFGestureFunctions.swift index e520c9f..f8d3b33 100644 --- a/Sources/OpenGestures/Extension/OGFGestureFunctions.swift +++ b/Sources/OpenGestures/Extension/OGFGestureFunctions.swift @@ -44,16 +44,17 @@ public func OGFGestureNodeCoordinatorCreate( } #endif +#if canImport(ObjectiveC) @_cdecl("OGFGestureComponentControllerSetNode") +#endif public func ogfGestureComponentControllerSetNode( _ controller: AnyObject, _ node: (any OGFGestureNode)? ) { let controller = unsafeBitCast(controller, to: AnyGestureComponentController.self) - let shim = unsafeBitCast(node, to: AnyGestureNodeShim.self) let newNode: AnyGestureNode? if let node { - newNode = shim.node + newNode = unsafeBitCast(node, to: AnyGestureNodeShim.self).node } else { newNode = nil } diff --git a/Sources/OpenGestures/Extension/OGFGestureNode.swift b/Sources/OpenGestures/Extension/OGFGestureNode.swift index e5166f9..b8cb709 100644 --- a/Sources/OpenGestures/Extension/OGFGestureNode.swift +++ b/Sources/OpenGestures/Extension/OGFGestureNode.swift @@ -5,11 +5,11 @@ // 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 { @@ -26,5 +26,3 @@ class AnyGestureNodeShim: NSObject, @unchecked Sendable { // _openGesturesBaseClassAbstractMethod() // } } - -#endif diff --git a/Tests/OpenGesturesTests/Component/MovementGateTests.swift b/Tests/OpenGesturesTests/Component/MovementGateTests.swift index abec22e..38d564a 100644 --- a/Tests/OpenGesturesTests/Component/MovementGateTests.swift +++ b/Tests/OpenGesturesTests/Component/MovementGateTests.swift @@ -4,6 +4,7 @@ // // Generated +import OpenCoreGraphicsShims import OpenGestures import Testing