From 84c4a4ce399a0717a7308227258bc5559d35a9fb Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 20 Apr 2026 00:30:26 +0800 Subject: [PATCH 01/14] Update GestureNodeCoordinator --- .../GestureNode/GestureNodeContainer.swift | 15 ++++ .../GestureNode/GestureNodeCoordinator.swift | 75 +++++++++---------- .../GestureNode/GestureNodeDelegate.swift | 35 +++++---- 3 files changed, 71 insertions(+), 54 deletions(-) diff --git a/Sources/OpenGestures/GestureNode/GestureNodeContainer.swift b/Sources/OpenGestures/GestureNode/GestureNodeContainer.swift index 4754b02..98f3125 100644 --- a/Sources/OpenGestures/GestureNode/GestureNodeContainer.swift +++ b/Sources/OpenGestures/GestureNode/GestureNodeContainer.swift @@ -1,3 +1,10 @@ +// +// GestureNodeContainer.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: WIP + // MARK: - GestureNodeContainer /// Protocol for querying node hierarchy in the view tree. @@ -6,3 +13,11 @@ public protocol GestureNodeContainer: AnyObject, Sendable { func isDescendant(of container: any GestureNodeContainer, referenceNode: AnyGestureNode) -> Bool func isDeeper(than container: any GestureNodeContainer, referenceNode: AnyGestureNode) -> Bool } + +// MARK: - GestureNodeListener [WIP] + +package protocol GestureNodeListener: AnyObject { + // TODO +} + +// TODO: GestureNodeCoordinator: GestureNodeListener diff --git a/Sources/OpenGestures/GestureNode/GestureNodeCoordinator.swift b/Sources/OpenGestures/GestureNode/GestureNodeCoordinator.swift index e39d85e..96734ad 100644 --- a/Sources/OpenGestures/GestureNode/GestureNodeCoordinator.swift +++ b/Sources/OpenGestures/GestureNode/GestureNodeCoordinator.swift @@ -1,7 +1,8 @@ // MARK: - GestureNodeCoordinator /// Central coordinator that manages gesture node updates and conflict resolution. -public final class GestureNodeCoordinator: @unchecked Sendable { +@objc +public final class GestureNodeCoordinator: NSObject, @unchecked Sendable { // MARK: - Callbacks @@ -9,13 +10,32 @@ public final class GestureNodeCoordinator: @unchecked Sendable { public var willProcessUpdateQueue: (() -> Void)? public var didUpdate: (() -> Void)? - // MARK: - Internal State + // MARK: - Tracked Nodes + + /// All nodes this coordinator currently owns. + private var nodes: Set = [] + + // MARK: - Configuration private let timeSource: any TimeSource - private let updateDriver: (any GestureUpdateDriver)? - private let shouldTrackTransitiveDependencies: Bool - private var nodes: Set = [] - private var _nodeRefs: [AnyGestureNode] = [] + + // MARK: - Conflict Resolution + + private let failureDependencyGraph = FailureDependencyGraph() + private var exclusionPool = ExclusionPool() + + // MARK: - Pending Work + + private var nodesNeedingUpdate: Set = [] + private var nodesNeedingReset: Set = [] + private var isProcessingUpdates: Bool = false + private var synchronousNodeUpdates: [GestureNodeID] = [] + + // MARK: - Update Driver + + private let updateDriver: any GestureUpdateDriver + private var updateDriverToken: GestureUpdateDriverToken? + private var resetTracker: SubgraphResetTracker = SubgraphResetTracker() // MARK: - Init @@ -24,57 +44,34 @@ public final class GestureNodeCoordinator: @unchecked Sendable { updateDriver: (any GestureUpdateDriver)? = nil, shouldTrackTransitiveDependencies: Bool = false ) { - self.timeSource = timeSource - self.updateDriver = updateDriver - self.shouldTrackTransitiveDependencies = shouldTrackTransitiveDependencies - } - - // MARK: - Node Management - - func addNode(_ node: AnyGestureNode) { - let oid = ObjectIdentifier(node) - if nodes.insert(oid).inserted { - _nodeRefs.append(node) - } - } - - func removeNode(_ node: AnyGestureNode) { - let oid = ObjectIdentifier(node) - if nodes.remove(oid) != nil { - _nodeRefs.removeAll { $0 === node } - } + // TODO + fatalError("TODO") } // MARK: - Update Dispatch - /// Enqueues updates for the given nodes. public func enqueueUpdates( nodes: [AnyGestureNode], reason: String, closure: (AnyGestureNode) -> Void ) { + // TODO + willUpdate?() for node in nodes { guard !node.options.contains(.isDisabled), node.container != nil else { continue } closure(node) + nodesNeedingUpdate.insert(node) } } - /// Processes all queued updates. public func processUpdates(reason: String) { - willProcessUpdateQueue?() - willUpdate?() - // TODO: Actual update processing — iterate queued phase transitions, - // run ExclusionPool + FailureDependencyGraph resolution - didUpdate?() - } - - deinit { - // Clear coordinator back-refs on all managed nodes - for _ in _nodeRefs { - // node's coordinator back-ref would be cleared here - } + // TODO } } + +// TODO: SubgraphResetTracker + +struct SubgraphResetTracker {} diff --git a/Sources/OpenGestures/GestureNode/GestureNodeDelegate.swift b/Sources/OpenGestures/GestureNode/GestureNodeDelegate.swift index 8ecb02a..e9b8310 100644 --- a/Sources/OpenGestures/GestureNode/GestureNodeDelegate.swift +++ b/Sources/OpenGestures/GestureNode/GestureNodeDelegate.swift @@ -1,30 +1,35 @@ +// +// GestureNodeDelegate.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + // MARK: - GestureNodeDelegate /// Protocol for receiving gesture node state change notifications. public protocol GestureNodeDelegate: AnyObject, Sendable { associatedtype Value: Sendable - func gestureNodeShouldActivate(_ node: GestureNode) -> Bool - func gestureNode(_ node: GestureNode, didEnqueuePhase phase: GesturePhase) - func gestureNode(_ node: GestureNode, didUpdatePhase newPhase: GesturePhase, oldPhase: GesturePhase) + func gestureNodeShouldActivate( + _ node: GestureNode + ) -> Bool + func gestureNode( _ node: GestureNode, - roleForRelationType type: GestureRelationType, - direction: GestureRelationDirection, - relatedNode: AnyGestureNode - ) -> GestureRelationRole? -} + didEnqueuePhase phase: GesturePhase + ) -// MARK: - Default implementations + func gestureNode( + _ node: GestureNode, + didUpdatePhase newPhase: GesturePhase, + oldPhase: GesturePhase + ) -extension GestureNodeDelegate { - public func gestureNodeShouldActivate(_ node: GestureNode) -> Bool { true } - public func gestureNode(_ node: GestureNode, didEnqueuePhase phase: GesturePhase) {} - public func gestureNode(_ node: GestureNode, didUpdatePhase newPhase: GesturePhase, oldPhase: GesturePhase) {} - public func gestureNode( + func gestureNode( _ node: GestureNode, roleForRelationType type: GestureRelationType, direction: GestureRelationDirection, relatedNode: AnyGestureNode - ) -> GestureRelationRole? { nil } + ) -> GestureRelationRole? } From cad8b7670a7f23066988dd856274b2ab42fdb0cc Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 21 Apr 2026 01:02:46 +0800 Subject: [PATCH 02/14] Update GestureComponentController --- .../GestureComponentController.swift | 136 ++++++++++-------- 1 file changed, 80 insertions(+), 56 deletions(-) diff --git a/Sources/OpenGestures/Component/GestureComponentController.swift b/Sources/OpenGestures/Component/GestureComponentController.swift index d8ce036..ab10aeb 100644 --- a/Sources/OpenGestures/Component/GestureComponentController.swift +++ b/Sources/OpenGestures/Component/GestureComponentController.swift @@ -1,82 +1,106 @@ -// MARK: - AnyGestureComponentController +// +// GestureComponentController.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: WIP -/// Type-erased base for gesture component controllers. -open class AnyGestureComponentController: @unchecked Sendable { +// MARK: - GestureComponentController [WIP] - /// Weak reference to the bound node. - public weak var node: AnyGestureNode? +/// Concrete controller wrapping a specific `GestureComponent`. +public final class GestureComponentController: AnyGestureComponentController, @unchecked Sendable { - public init() {} + 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 init(component: C, timeScheduler: any TimeScheduler) { + self.component = component + self.timeScheduler = timeScheduler + super.init() + } - /// Handles incoming events and drives the component's update cycle. - open func handleEvents(_ events: [any Event]) { - fatalError("Subclass must override") + public override var traits: GestureTraitCollection? { + component.traits() } - /// Checks if this controller can handle events of a given type and count. - open func canHandleEvents(ofType: E.Type, count: Int) -> Bool { - false + public override var timeSource: any TimeSource { + timeScheduler } - /// Resets the controller and its component. - open func reset() { - fatalError("Subclass must override") + public override func canHandleEvents(ofType: E.Type, count: Int) -> Bool { + component.capacity(for: ofType) >= count + } + + 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) + } } - /// Returns the component's traits. - open var traits: GestureTraitCollection? { nil } + public override func reset() { + component.reset() + startTime = nil + } } -// MARK: - GestureComponentController +// MARK: - AnyGestureComponentController -/// Concrete controller wrapping a specific GestureComponent. -public final class GestureComponentController: AnyGestureComponentController, @unchecked Sendable { +/// Type-erased base for gesture component controllers. +open class AnyGestureComponentController: @unchecked Sendable { - public var component: C - private let timeSource: any TimeSource - private var cachedStartTime: Timestamp? + /// Weak back-reference to the owning gesture node. + open weak var node: AnyGestureNode? - public init(component: C, timeSource: any TimeSource) { - self.component = component - self.timeSource = timeSource + /// Traits exposed by the wrapped component. + open var traits: GestureTraitCollection? { + _openGesturesBaseClassAbstractMethod() } - public override func handleEvents(_ events: [any Event]) { - let currentTime = timeSource.timestamp - let startTime = cachedStartTime ?? currentTime - if cachedStartTime == nil { - cachedStartTime = currentTime - } - - let context = GestureComponentContext(startTime: startTime, currentTime: currentTime) + /// Time source used by `handleEvents` to build `GestureComponentContext`. + open var timeSource: any TimeSource { + _openGesturesBaseClassAbstractMethod() + } - do { - 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) - } - } catch { - // Handle error - } + /// Whether this controller can consume `count` events of the given type. + open func canHandleEvents(ofType: E.Type, count: Int) -> Bool { + _openGesturesBaseClassAbstractMethod() } - public override func reset() { - component.reset() - cachedStartTime = nil + /// Whether this controller can consume a single event. + open func canHandleEvent(_ event: E) -> Bool { + _openGesturesBaseClassAbstractMethod() } - public override var traits: GestureTraitCollection? { - component.traits() + /// Drives the wrapped component with the given events. + open func handleEvents(_ events: [E]) throws { + _openGesturesBaseClassAbstractMethod() } - public override func canHandleEvents(ofType: E.Type, count: Int) -> Bool { - component.capacity(for: ofType) >= count + /// Resets the controller and its wrapped component. + open func reset() { + _openGesturesBaseClassAbstractMethod() } + + package init() {} } From fd5ca3eef2f999f27fa99ed85f5ee1953024649e Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 21 Apr 2026 01:06:06 +0800 Subject: [PATCH 03/14] Update folder structure --- .../Extension/OGFGestureNode.swift | 26 ++++++ .../GestureNode/GestureNode.swift | 74 ----------------- .../AnyGestureNode.swift | 37 +++++---- Sources/OpenGestures/Node/GestureNode.swift | 80 +++++++++++++++++++ .../GestureNodeContainer.swift | 0 .../GestureNodeCoordinator.swift | 0 .../GestureNodeDelegate.swift | 0 .../{GestureNode => Node}/GestureNodeID.swift | 0 .../GestureNodeOptions.swift | 0 9 files changed, 129 insertions(+), 88 deletions(-) create mode 100644 Sources/OpenGestures/Extension/OGFGestureNode.swift delete mode 100644 Sources/OpenGestures/GestureNode/GestureNode.swift rename Sources/OpenGestures/{GestureNode => Node}/AnyGestureNode.swift (78%) create mode 100644 Sources/OpenGestures/Node/GestureNode.swift rename Sources/OpenGestures/{GestureNode => Node}/GestureNodeContainer.swift (100%) rename Sources/OpenGestures/{GestureNode => Node}/GestureNodeCoordinator.swift (100%) rename Sources/OpenGestures/{GestureNode => Node}/GestureNodeDelegate.swift (100%) rename Sources/OpenGestures/{GestureNode => Node}/GestureNodeID.swift (100%) rename Sources/OpenGestures/{GestureNode => Node}/GestureNodeOptions.swift (100%) diff --git a/Sources/OpenGestures/Extension/OGFGestureNode.swift b/Sources/OpenGestures/Extension/OGFGestureNode.swift new file mode 100644 index 0000000..2a04ff3 --- /dev/null +++ b/Sources/OpenGestures/Extension/OGFGestureNode.swift @@ -0,0 +1,26 @@ +// +// OGFGestureNode.swift +// OpenGestures +// +// Created by Kyle on 4/19/26. +// + +#if canImport(ObjectiveC) + +import Foundation + +@objc +class AnyGestureNodeShim: NSObject, @unchecked Sendable { + +// override var container: (any GestureNodeContainer)? { +// didSet { +// // TODO +// } +// } +// +// override func abort() throws { +// _openGesturesBaseClassAbstractMethod() +// } +} + +#endif diff --git a/Sources/OpenGestures/GestureNode/GestureNode.swift b/Sources/OpenGestures/GestureNode/GestureNode.swift deleted file mode 100644 index c654d0b..0000000 --- a/Sources/OpenGestures/GestureNode/GestureNode.swift +++ /dev/null @@ -1,74 +0,0 @@ -// MARK: - GestureNode - -/// A concrete gesture node with a typed Value. -public final class GestureNode: AnyGestureNode, @unchecked Sendable { - - // MARK: - Stored Properties - - // We use a type-erased callback approach for the delegate since Swift doesn't support - // `weak var delegate: (any GestureNodeDelegate)?` directly without primary - // associated types on the protocol. - private var _didUpdatePhase: ((GesturePhase, GesturePhase) -> Void)? - private var _shouldActivate: (() -> Bool)? - - package var phaseQueue: GesturePhaseQueue = GesturePhaseQueue( - timeSource: nil, - currentPhase: .idle, - pendingPhases: RingBuffer(capacity: 5, emptyValue: .idle) - ) - - // MARK: - Init - - public override init( - traits: GestureTraitCollection? = nil, - tag: GestureTag? = nil, - relations: [GestureRelation] = [] - ) { - super.init(traits: traits, tag: tag, relations: relations) - } - - // MARK: - Delegate - - public func setDelegate(_ delegate: D) where D.Value == Value { - _shouldActivate = { [weak delegate] in - delegate?.gestureNodeShouldActivate(self) ?? true - } - _didUpdatePhase = { [weak delegate, weak self] newPhase, oldPhase in - guard let self else { return } - delegate?.gestureNode(self, didUpdatePhase: newPhase, oldPhase: oldPhase) - } - } - - // MARK: - Update - - public func update(value: Value, isFinalUpdate: Bool) throws { - let oldPhase = phaseQueue.currentPhase - let newPhase: GesturePhase = isFinalUpdate ? .ended(value: value) : .active(value: value) - phaseQueue.currentPhase = newPhase - _didUpdatePhase?(newPhase, oldPhase) - } - - public override func update(someValue: Any, isFinalUpdate: Bool) throws { - guard let typedValue = someValue as? Value else { - fatalError("Type mismatch: expected \(Value.self), got \(type(of: someValue))") - } - try update(value: typedValue, isFinalUpdate: isFinalUpdate) - } - - // MARK: - Abort / Fail - - public override func abort() throws { - let oldPhase = phaseQueue.currentPhase - let newPhase: GesturePhase = .failed(reason: .aborted) - phaseQueue.currentPhase = newPhase - _didUpdatePhase?(newPhase, oldPhase) - } - - public override func fail(with error: Error) throws { - let oldPhase = phaseQueue.currentPhase - // TODO: .error(Error) case once non-Sendable handling resolved - let newPhase: GesturePhase = .failed(reason: .aborted) - phaseQueue.currentPhase = newPhase - _didUpdatePhase?(newPhase, oldPhase) - } -} diff --git a/Sources/OpenGestures/GestureNode/AnyGestureNode.swift b/Sources/OpenGestures/Node/AnyGestureNode.swift similarity index 78% rename from Sources/OpenGestures/GestureNode/AnyGestureNode.swift rename to Sources/OpenGestures/Node/AnyGestureNode.swift index f0a2306..4590ee5 100644 --- a/Sources/OpenGestures/GestureNode/AnyGestureNode.swift +++ b/Sources/OpenGestures/Node/AnyGestureNode.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - AnyGestureNode /// Type-erased base class for gesture nodes. -open class AnyGestureNode: Hashable, Identifiable, @unchecked Sendable { +open class AnyGestureNode: Identifiable, Hashable, @unchecked Sendable { // MARK: - Static ID counter @@ -14,13 +14,13 @@ open class AnyGestureNode: Hashable, Identifiable, @unchecked Sendable { public let id: GestureNodeID public var tag: GestureTag? public var traits: GestureTraitCollection? - public var options: GestureNodeOptions - public weak var container: (any GestureNodeContainer)? + open var options: GestureNodeOptions + open weak var container: (any GestureNodeContainer)? private var _trackedEventIDs: Set = [] // MARK: - Init - public init( + package init( traits: GestureTraitCollection? = nil, tag: GestureTag? = nil, relations: [GestureRelation] = [] @@ -39,7 +39,7 @@ open class AnyGestureNode: Hashable, Identifiable, @unchecked Sendable { package var relationMap = RelationMap() - public var relations: [GestureRelation] { + open var relations: [GestureRelation] { relationMap.toRelations() } @@ -80,14 +80,13 @@ open class AnyGestureNode: Hashable, Identifiable, @unchecked Sendable { // MARK: - Update / Abort / Fail /// Type-erased update. Subclass (GestureNode) overrides. - open func update(someValue: Any, isFinalUpdate: Bool) throws { + open func update(someValue: T, isFinalUpdate: Bool) throws { fatalError("Subclass must override") } /// Aborts the gesture, setting phase to .failed(.aborted). open func abort() throws { - // Constructs .failed(.aborted) and dispatches - fatalError("Subclass must override") + try fail(with: _GestureAbortError()) } /// Fails the gesture with an error. @@ -98,28 +97,38 @@ open class AnyGestureNode: Hashable, Identifiable, @unchecked Sendable { // MARK: - Debug public var debugLabel: String { - "\(type(of: self))(\(id))" + let address = String(UInt(bitPattern: ObjectIdentifier(self)), radix: 16) + return "\(type(of: self)) <0x\(address) \(id)>" } - // MARK: - Hashable / Equatable +} + +// MARK: - Hashable / Comparable +extension AnyGestureNode { public static func == (lhs: AnyGestureNode, rhs: AnyGestureNode) -> Bool { - lhs.id == rhs.id + lhs === rhs } public func hash(into hasher: inout Hasher) { - hasher.combine(id) + hasher.combine(ObjectIdentifier(self)) } } -// MARK: - Comparable - extension AnyGestureNode: Comparable { public static func < (lhs: AnyGestureNode, rhs: AnyGestureNode) -> Bool { lhs.id < rhs.id } } +// MARK: - _GestureAbortError + +/// Internal sentinel error dispatched through `fail(with:)` when a gesture is +/// aborted via `AnyGestureNode.abort()`. +package struct _GestureAbortError: Error { + package init() {} +} + // MARK: - ManagedAtomic (minimal) /// Minimal atomic counter for node ID generation. diff --git a/Sources/OpenGestures/Node/GestureNode.swift b/Sources/OpenGestures/Node/GestureNode.swift new file mode 100644 index 0000000..e556044 --- /dev/null +++ b/Sources/OpenGestures/Node/GestureNode.swift @@ -0,0 +1,80 @@ +// MARK: - GestureNode + +/// A concrete gesture node with a typed Value. +/// +/// Non-final so application code can subclass. Apple's symbol table exposes +/// dispatch thunks and a method-lookup function, confirming the class is +/// designed to be subclassed. +public class GestureNode: AnyGestureNode, @unchecked Sendable { + + // MARK: - Stored Properties + + /// Weak reference to the typed delegate. Primary associated type on + /// `GestureNodeDelegate` lets us spell this directly. + public weak var delegate: (any GestureNodeDelegate)? + + package var phaseQueue: GesturePhaseQueue = GesturePhaseQueue( + timeSource: nil, + currentPhase: .idle, + pendingPhases: RingBuffer(capacity: 5, emptyValue: .idle) + ) + + // MARK: - Init + + public override init( + traits: GestureTraitCollection? = nil, + tag: GestureTag? = nil, + relations: [GestureRelation] = [] + ) { + super.init(traits: traits, tag: tag, relations: relations) + } + + /// Zero-arg convenience init. Apple exposes `__allocating_init()` on + /// `GestureNode`. + public convenience init() { + self.init(traits: nil, tag: nil, relations: []) + } + + // MARK: - Phase + + /// The currently committed phase, as observed after the last + /// `processUpdates` drain. + public var phase: GesturePhase { + phaseQueue.currentPhase + } + + /// The most recently enqueued phase. May differ from `phase` between + /// `enqueueUpdates` and the next `processUpdates` drain. + /// + /// TODO: track the pending tail separately once the coordinator's resolve + /// logic splits committed and pending phases. + public var latestPhase: GesturePhase { + phaseQueue.currentPhase + } + + // MARK: - Update + + public func update(value: Value, isFinalUpdate: Bool) throws { + let oldPhase = phaseQueue.currentPhase + let newPhase: GesturePhase = isFinalUpdate ? .ended(value: value) : .active(value: value) + phaseQueue.currentPhase = newPhase + delegate?.gestureNode(self, didUpdatePhase: newPhase, oldPhase: oldPhase) + } + + public override func update(someValue: T, isFinalUpdate: Bool) throws { + guard let typedValue = someValue as? Value else { + fatalError("Type mismatch: expected \(Value.self), got \(type(of: someValue))") + } + try update(value: typedValue, isFinalUpdate: isFinalUpdate) + } + + // MARK: - Fail + + public override func fail(with error: Error) throws { + let oldPhase = phaseQueue.currentPhase + // TODO: .error(Error) case once non-Sendable handling resolved + let newPhase: GesturePhase = .failed(reason: .aborted) + phaseQueue.currentPhase = newPhase + delegate?.gestureNode(self, didUpdatePhase: newPhase, oldPhase: oldPhase) + } +} diff --git a/Sources/OpenGestures/GestureNode/GestureNodeContainer.swift b/Sources/OpenGestures/Node/GestureNodeContainer.swift similarity index 100% rename from Sources/OpenGestures/GestureNode/GestureNodeContainer.swift rename to Sources/OpenGestures/Node/GestureNodeContainer.swift diff --git a/Sources/OpenGestures/GestureNode/GestureNodeCoordinator.swift b/Sources/OpenGestures/Node/GestureNodeCoordinator.swift similarity index 100% rename from Sources/OpenGestures/GestureNode/GestureNodeCoordinator.swift rename to Sources/OpenGestures/Node/GestureNodeCoordinator.swift diff --git a/Sources/OpenGestures/GestureNode/GestureNodeDelegate.swift b/Sources/OpenGestures/Node/GestureNodeDelegate.swift similarity index 100% rename from Sources/OpenGestures/GestureNode/GestureNodeDelegate.swift rename to Sources/OpenGestures/Node/GestureNodeDelegate.swift diff --git a/Sources/OpenGestures/GestureNode/GestureNodeID.swift b/Sources/OpenGestures/Node/GestureNodeID.swift similarity index 100% rename from Sources/OpenGestures/GestureNode/GestureNodeID.swift rename to Sources/OpenGestures/Node/GestureNodeID.swift diff --git a/Sources/OpenGestures/GestureNode/GestureNodeOptions.swift b/Sources/OpenGestures/Node/GestureNodeOptions.swift similarity index 100% rename from Sources/OpenGestures/GestureNode/GestureNodeOptions.swift rename to Sources/OpenGestures/Node/GestureNodeOptions.swift From 3891aefd5d0dad1c992b38e150644cbdbcadd440 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 24 Apr 2026 11:14:10 +0800 Subject: [PATCH 04/14] Update GestureNode --- .../OpenGestures/Node/AnyGestureNode.swift | 149 ---------------- Sources/OpenGestures/Node/GestureNode.swift | 162 +++++++++++++++++- 2 files changed, 158 insertions(+), 153 deletions(-) delete mode 100644 Sources/OpenGestures/Node/AnyGestureNode.swift diff --git a/Sources/OpenGestures/Node/AnyGestureNode.swift b/Sources/OpenGestures/Node/AnyGestureNode.swift deleted file mode 100644 index 4590ee5..0000000 --- a/Sources/OpenGestures/Node/AnyGestureNode.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation - -// MARK: - AnyGestureNode - -/// Type-erased base class for gesture nodes. -open class AnyGestureNode: Identifiable, Hashable, @unchecked Sendable { - - // MARK: - Static ID counter - - private static let _nextID = ManagedAtomic(0) - - // MARK: - Stored Properties - - public let id: GestureNodeID - public var tag: GestureTag? - public var traits: GestureTraitCollection? - open var options: GestureNodeOptions - open weak var container: (any GestureNodeContainer)? - private var _trackedEventIDs: Set = [] - - // MARK: - Init - - package init( - traits: GestureTraitCollection? = nil, - tag: GestureTag? = nil, - relations: [GestureRelation] = [] - ) { - let rawID = Self._nextID.wrappingIncrementThenLoad() - self.id = GestureNodeID(rawValue: rawID) - self.tag = tag - self.traits = traits - self.options = [] - for relation in relations { - addRelation(relation) - } - } - - // MARK: - Relations - - package var relationMap = RelationMap() - - open var relations: [GestureRelation] { - relationMap.toRelations() - } - - open func addRelation(_ relation: GestureRelation) { - relationMap.addRelation(relation) - } - - open func removeRelation(_ relation: GestureRelation) { - relationMap.removeRelation(relation) - } - - open func addRelations(_ relations: [GestureRelation]) { - for relation in relations { - addRelation(relation) - } - } - - open func removeRelations(_ relations: [GestureRelation]) { - for relation in relations { - removeRelation(relation) - } - } - - // MARK: - Event Tracking - - public func startTrackingEvents(with eventIDs: [EventID]) { - for id in eventIDs { - _trackedEventIDs.insert(id) - } - } - - public func stopTrackingEvents(with eventIDs: [EventID]) { - for id in eventIDs { - _trackedEventIDs.remove(id) - } - } - - // MARK: - Update / Abort / Fail - - /// Type-erased update. Subclass (GestureNode) overrides. - open func update(someValue: T, isFinalUpdate: Bool) throws { - fatalError("Subclass must override") - } - - /// Aborts the gesture, setting phase to .failed(.aborted). - open func abort() throws { - try fail(with: _GestureAbortError()) - } - - /// Fails the gesture with an error. - open func fail(with error: Error) throws { - fatalError("Subclass must override") - } - - // MARK: - Debug - - public var debugLabel: String { - let address = String(UInt(bitPattern: ObjectIdentifier(self)), radix: 16) - return "\(type(of: self)) <0x\(address) \(id)>" - } - -} - -// MARK: - Hashable / Comparable - -extension AnyGestureNode { - public static func == (lhs: AnyGestureNode, rhs: AnyGestureNode) -> Bool { - lhs === rhs - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } -} - -extension AnyGestureNode: Comparable { - public static func < (lhs: AnyGestureNode, rhs: AnyGestureNode) -> Bool { - lhs.id < rhs.id - } -} - -// MARK: - _GestureAbortError - -/// Internal sentinel error dispatched through `fail(with:)` when a gesture is -/// aborted via `AnyGestureNode.abort()`. -package struct _GestureAbortError: Error { - package init() {} -} - -// MARK: - ManagedAtomic (minimal) - -/// Minimal atomic counter for node ID generation. -private final class ManagedAtomic: @unchecked Sendable { - private var _value: T - private let _lock = NSLock() - - init(_ value: T) { - _value = value - } - - func wrappingIncrementThenLoad(ordering _: Any? = nil) -> T { - _lock.lock() - defer { _lock.unlock() } - _value &+= 1 - return _value - } -} diff --git a/Sources/OpenGestures/Node/GestureNode.swift b/Sources/OpenGestures/Node/GestureNode.swift index e556044..2799a90 100644 --- a/Sources/OpenGestures/Node/GestureNode.swift +++ b/Sources/OpenGestures/Node/GestureNode.swift @@ -1,4 +1,152 @@ -// MARK: - GestureNode +import Foundation +import Synchronization + +// MARK: - AnyGestureNode [WIP] + +/// Type-erased base class for gesture nodes. + +open class AnyGestureNode: Identifiable, Hashable, @unchecked Sendable { + + // MARK: - Static ID counter + + /// Monotonically increasing node-ID allocator. Apple's `Gestures.framework` + /// stores the counter in a plain static `UInt32` protected by `swift_once` + /// and bumps it via an ARM64 CAS loop with trap-on-overflow semantics; + /// `Synchronization.Atomic` reproduces that pattern directly. + private static let _nextID = Atomic(0) + + package static func _allocateID() -> UInt32 { + var current = _nextID.load(ordering: .relaxed) + while true { + let next = current + 1 + let (exchanged, actual) = _nextID.compareExchange( + expected: current, desired: next, ordering: .relaxed + ) + if exchanged { return next } + current = actual + } + } + + // MARK: - Stored Properties + + public let id: GestureNodeID + public var tag: GestureTag? + public var traits: GestureTraitCollection? + open var options: GestureNodeOptions + open weak var container: (any GestureNodeContainer)? + package var timeSource: (any TimeSource)? + package unowned var context: AnyObject? + package var debuglabelProvider: ((AnyGestureNode) -> String)? + package unowned var listener: (any GestureNodeListener)? + package var relationMap: RelationMap + package var trackedEvents: Set = [] + + // MARK: - Init [WIP] + + package init( + traits: GestureTraitCollection? = nil, + tag: GestureTag? = nil, + relations: [GestureRelation] = [] + ) { + self.id = GestureNodeID(rawValue: Self._allocateID()) + self.tag = tag + self.traits = traits + self.options = [] + for relation in relations { + addRelation(relation) + } + } + + // MARK: - Relations + + + open var relations: [GestureRelation] { + relationMap.toRelations() + } + + open func addRelation(_ relation: GestureRelation) { + relationMap.addRelation(relation) + } + + open func removeRelation(_ relation: GestureRelation) { + relationMap.removeRelation(relation) + } + + open func addRelations(_ relations: [GestureRelation]) { + for relation in relations { + addRelation(relation) + } + } + + open func removeRelations(_ relations: [GestureRelation]) { + for relation in relations { + removeRelation(relation) + } + } + + // MARK: - Event Tracking + + public func startTrackingEvents(with eventIDs: [EventID]) { + for id in eventIDs { + _trackedEventIDs.insert(id) + } + } + + public func stopTrackingEvents(with eventIDs: [EventID]) { + for id in eventIDs { + _trackedEventIDs.remove(id) + } + } + + // MARK: - Update / Abort / Fail + + /// Type-erased update. Subclass (`GestureNode`) overrides. + open func update(someValue: T, isFinalUpdate: Bool) throws { + _openGesturesBaseClassAbstractMethod() + } + + /// Aborts the gesture, transitioning its phase to + /// `.failed(reason: .aborted)`. Apple's `abort()` (at 0x26358) is a + /// direct-dispatch method that invokes an overridable vtable slot rather + /// than routing through `fail(with:)`; `GestureNode` provides the + /// concrete override. + open func abort() throws { + _openGesturesBaseClassAbstractMethod() + } + + /// Fails the gesture with an error. + open func fail(with error: Error) throws { + _openGesturesBaseClassAbstractMethod() + } + + // MARK: - Debug + + public var debugLabel: String { + let address = String(UInt(bitPattern: ObjectIdentifier(self)), radix: 16) + return "\(type(of: self)) <0x\(address) \(id)>" + } + +} + +// MARK: - Hashable / Comparable + +extension AnyGestureNode { + public static func == (lhs: AnyGestureNode, rhs: AnyGestureNode) -> Bool { + lhs === rhs + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +extension AnyGestureNode: Comparable { + public static func < (lhs: AnyGestureNode, rhs: AnyGestureNode) -> Bool { + lhs.id < rhs.id + } +} + +// MARK: - GestureNode [WIP] /// A concrete gesture node with a typed Value. /// @@ -68,13 +216,19 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { try update(value: typedValue, isFinalUpdate: isFinalUpdate) } - // MARK: - Fail + // MARK: - Abort / Fail - public override func fail(with error: Error) throws { + public override func abort() throws { let oldPhase = phaseQueue.currentPhase - // TODO: .error(Error) case once non-Sendable handling resolved let newPhase: GesturePhase = .failed(reason: .aborted) phaseQueue.currentPhase = newPhase delegate?.gestureNode(self, didUpdatePhase: newPhase, oldPhase: oldPhase) } + + public override func fail(with error: Error) throws { + let oldPhase = phaseQueue.currentPhase + let newPhase: GesturePhase = .failed(reason: .custom(error)) + phaseQueue.currentPhase = newPhase + delegate?.gestureNode(self, didUpdatePhase: newPhase, oldPhase: oldPhase) + } } From bf72c0f182b2d4749718bef31bde792e8ef15251 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 24 Apr 2026 17:52:27 +0800 Subject: [PATCH 05/14] Update AnyGestureNode --- .../OpenGestures/Core/GesturePhaseQueue.swift | 7 +- Sources/OpenGestures/Node/GestureNode.swift | 148 ++++++++++-------- .../GestureNodeCompatibilityTests.swift | 40 +++++ 3 files changed, 122 insertions(+), 73 deletions(-) create mode 100644 Tests/OpenGesturesCompatibilityTests/GestureNode/GestureNodeCompatibilityTests.swift diff --git a/Sources/OpenGestures/Core/GesturePhaseQueue.swift b/Sources/OpenGestures/Core/GesturePhaseQueue.swift index dd1ef31..77d12b3 100644 --- a/Sources/OpenGestures/Core/GesturePhaseQueue.swift +++ b/Sources/OpenGestures/Core/GesturePhaseQueue.swift @@ -14,9 +14,9 @@ package struct GesturePhaseQueue { package var pendingPhases: RingBuffer> package init( - timeSource: (any TimeSource)?, - currentPhase: GesturePhase, - pendingPhases: RingBuffer> + timeSource: (any TimeSource)? = nil, + currentPhase: GesturePhase = .idle, + pendingPhases: RingBuffer> = .init(capacity: 5, emptyValue: .idle) ) { self.timeSource = timeSource self.currentPhase = currentPhase @@ -39,4 +39,3 @@ extension GesturePhaseQueue { } extension GesturePhaseQueue.InvalidTransition: NestedCustomStringConvertible {} - diff --git a/Sources/OpenGestures/Node/GestureNode.swift b/Sources/OpenGestures/Node/GestureNode.swift index 2799a90..9187575 100644 --- a/Sources/OpenGestures/Node/GestureNode.swift +++ b/Sources/OpenGestures/Node/GestureNode.swift @@ -1,57 +1,51 @@ +// +// GestureNode.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: WIP + import Foundation import Synchronization -// MARK: - AnyGestureNode [WIP] +// MARK: - AnyGestureNode /// Type-erased base class for gesture nodes. - -open class AnyGestureNode: Identifiable, Hashable, @unchecked Sendable { +open class AnyGestureNode: Identifiable, @unchecked Sendable { // MARK: - Static ID counter - /// Monotonically increasing node-ID allocator. Apple's `Gestures.framework` - /// stores the counter in a plain static `UInt32` protected by `swift_once` - /// and bumps it via an ARM64 CAS loop with trap-on-overflow semantics; - /// `Synchronization.Atomic` reproduces that pattern directly. - private static let _nextID = Atomic(0) - - package static func _allocateID() -> UInt32 { - var current = _nextID.load(ordering: .relaxed) - while true { - let next = current + 1 - let (exchanged, actual) = _nextID.compareExchange( - expected: current, desired: next, ordering: .relaxed - ) - if exchanged { return next } - current = actual - } + private static let counter = Atomic(0) + + @inline(__always) + private static func makeUniqueID() -> UInt32 { + let (_, id) = counter.add(1, ordering: .relaxed) + return id } // MARK: - Stored Properties - public let id: GestureNodeID + public let id: GestureNodeID = GestureNodeID(rawValue: makeUniqueID()) public var tag: GestureTag? public var traits: GestureTraitCollection? - open var options: GestureNodeOptions + open var options: GestureNodeOptions = [] open weak var container: (any GestureNodeContainer)? package var timeSource: (any TimeSource)? package unowned var context: AnyObject? - package var debuglabelProvider: ((AnyGestureNode) -> String)? + package var debugLabelProvider: ((AnyGestureNode) -> String)? package unowned var listener: (any GestureNodeListener)? - package var relationMap: RelationMap + package var relationMap: RelationMap = RelationMap() package var trackedEvents: Set = [] - // MARK: - Init [WIP] + // MARK: - Init package init( traits: GestureTraitCollection? = nil, tag: GestureTag? = nil, relations: [GestureRelation] = [] ) { - self.id = GestureNodeID(rawValue: Self._allocateID()) self.tag = tag self.traits = traits - self.options = [] for relation in relations { addRelation(relation) } @@ -59,7 +53,6 @@ open class AnyGestureNode: Identifiable, Hashable, @unchecked Sendable { // MARK: - Relations - open var relations: [GestureRelation] { relationMap.toRelations() } @@ -88,49 +81,66 @@ open class AnyGestureNode: Identifiable, Hashable, @unchecked Sendable { public func startTrackingEvents(with eventIDs: [EventID]) { for id in eventIDs { - _trackedEventIDs.insert(id) + trackedEvents.insert(id) } } public func stopTrackingEvents(with eventIDs: [EventID]) { for id in eventIDs { - _trackedEventIDs.remove(id) + trackedEvents.remove(id) } } - // MARK: - Update / Abort / Fail + // MARK: - Update / Abort / Fail [WIP] - /// Type-erased update. Subclass (`GestureNode`) overrides. open func update(someValue: T, isFinalUpdate: Bool) throws { _openGesturesBaseClassAbstractMethod() } - /// Aborts the gesture, transitioning its phase to - /// `.failed(reason: .aborted)`. Apple's `abort()` (at 0x26358) is a - /// direct-dispatch method that invokes an overridable vtable slot rather - /// than routing through `fail(with:)`; `GestureNode` provides the - /// concrete override. - open func abort() throws { + public final func abort() throws { + try _abort() + } + + package func _abort() throws { _openGesturesBaseClassAbstractMethod() } - /// Fails the gesture with an error. open func fail(with error: Error) throws { _openGesturesBaseClassAbstractMethod() } // MARK: - Debug - public var debugLabel: String { - let address = String(UInt(bitPattern: ObjectIdentifier(self)), radix: 16) - return "\(type(of: self)) <0x\(address) \(id)>" + public final var debugLabel: String { + var parts: [String] = [] + let label: String + if let debugLabelProvider { + label = debugLabelProvider(self) + } else { + let address = String(UInt(bitPattern: ObjectIdentifier(self)), radix: 16, uppercase: false) + label = "\(type(of: self)): 0x\(address)" + } + parts.append(label) + if let tag { + parts.append(tag.description) + } + var pairs: [(String, String)] = [] + pairs.append(("id", id.description)) + pairs.append(("phase", describePhaseQueue())) + let header = parts.joined(separator: " ") + let pairResult = pairs.map { $0 + " = " + $1 }.joined(separator: "; ") + return "<" + header + "; " + pairResult + ">" } + // FIXME + package func describePhaseQueue() -> String { + "" + } } // MARK: - Hashable / Comparable -extension AnyGestureNode { +extension AnyGestureNode: Hashable { public static func == (lhs: AnyGestureNode, rhs: AnyGestureNode) -> Bool { lhs === rhs } @@ -142,60 +152,53 @@ extension AnyGestureNode { extension AnyGestureNode: Comparable { public static func < (lhs: AnyGestureNode, rhs: AnyGestureNode) -> Bool { - lhs.id < rhs.id + guard let lhsContainer = lhs.container, + let rhsContainer = rhs.container else { + return rhs.container != nil + } + guard lhsContainer === rhsContainer else { + return rhsContainer.isDeeper(than: lhsContainer, referenceNode: lhs) + } + guard let lhsIndex = lhsContainer.index(of: lhs), + let rhsIndex = rhsContainer.index(of: rhs) else { + return false + } + return lhsIndex < rhsIndex } } // MARK: - GestureNode [WIP] -/// A concrete gesture node with a typed Value. -/// -/// Non-final so application code can subclass. Apple's symbol table exposes -/// dispatch thunks and a method-lookup function, confirming the class is -/// designed to be subclassed. public class GestureNode: AnyGestureNode, @unchecked Sendable { // MARK: - Stored Properties - /// Weak reference to the typed delegate. Primary associated type on - /// `GestureNodeDelegate` lets us spell this directly. public weak var delegate: (any GestureNodeDelegate)? - package var phaseQueue: GesturePhaseQueue = GesturePhaseQueue( - timeSource: nil, - currentPhase: .idle, - pendingPhases: RingBuffer(capacity: 5, emptyValue: .idle) - ) + package var phaseQueue: GesturePhaseQueue // MARK: - Init public override init( - traits: GestureTraitCollection? = nil, - tag: GestureTag? = nil, - relations: [GestureRelation] = [] + traits: GestureTraitCollection?, + tag: GestureTag?, + relations: [GestureRelation] ) { + delegate = nil + phaseQueue = .init() super.init(traits: traits, tag: tag, relations: relations) } - /// Zero-arg convenience init. Apple exposes `__allocating_init()` on - /// `GestureNode`. public convenience init() { - self.init(traits: nil, tag: nil, relations: []) + self.init(traits: nil, tag: nil, relations: .default) } // MARK: - Phase - /// The currently committed phase, as observed after the last - /// `processUpdates` drain. public var phase: GesturePhase { phaseQueue.currentPhase } - /// The most recently enqueued phase. May differ from `phase` between - /// `enqueueUpdates` and the next `processUpdates` drain. - /// - /// TODO: track the pending tail separately once the coordinator's resolve - /// logic splits committed and pending phases. public var latestPhase: GesturePhase { phaseQueue.currentPhase } @@ -218,7 +221,7 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { // MARK: - Abort / Fail - public override func abort() throws { + package override func _abort() throws { let oldPhase = phaseQueue.currentPhase let newPhase: GesturePhase = .failed(reason: .aborted) phaseQueue.currentPhase = newPhase @@ -231,4 +234,11 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { phaseQueue.currentPhase = newPhase delegate?.gestureNode(self, didUpdatePhase: newPhase, oldPhase: oldPhase) } + + // MARK: - Debug + + // FIXME + package override func describePhaseQueue() -> String { + "\(phaseQueue.currentPhase)" + } } diff --git a/Tests/OpenGesturesCompatibilityTests/GestureNode/GestureNodeCompatibilityTests.swift b/Tests/OpenGesturesCompatibilityTests/GestureNode/GestureNodeCompatibilityTests.swift new file mode 100644 index 0000000..1c45c18 --- /dev/null +++ b/Tests/OpenGesturesCompatibilityTests/GestureNode/GestureNodeCompatibilityTests.swift @@ -0,0 +1,40 @@ +// +// GestureNodeCompatibilityTests.swift +// OpenGesturesCompatibilityTests + +import Testing + +// MARK: - GestureNodeCompatibilityTests + +@Suite +struct GestureNodeCompatibilityTests { + // MARK: - debugLabel + + @Test + func debugLabelWithValue() throws { + let node = GestureNode(traits: nil, tag: .init(rawValue: "test"), relations: []) +// try node.update(value: 3, isFinalUpdate: false) // Link issue +// try node.update(someValue: 3, isFinalUpdate: false) + + let label = node.debugLabel + let regex = #/\: 0x[0-9a-f]+ "test"; id = \d+; phase = idle>/# + #expect(label.wholeMatch(of: regex) != nil, "\(label) does not match regex") + } + + @Test( + arguments: [ + ( + GestureNode(traits: nil, tag: .init(rawValue: "test"), relations: []), + #/\: 0x[0-9a-f]+ "test"; id = \d+; phase = idle>/# + ), + ( + GestureNode(traits: nil, tag: .init(rawValue: "test2"), relations: []), + #/\: 0x[0-9a-f]+ "test2"; id = \d+; phase = idle>/# + ), + ] as [(AnyGestureNode, Regex)] + ) + func debugLabel(node: AnyGestureNode, _ regex: Regex) throws { + let label = node.debugLabel + #expect(label.wholeMatch(of: regex) != nil, "\(label) does not match regex") + } +} From 05ccbc1ab130c8a3b1dff8b6996d1f6b59dac6d2 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 24 Apr 2026 23:07:49 +0800 Subject: [PATCH 06/14] Update relations API --- Sources/OpenGestures/Node/GestureNode.swift | 46 +++++++++++++++------ 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/Sources/OpenGestures/Node/GestureNode.swift b/Sources/OpenGestures/Node/GestureNode.swift index 9187575..a76a76f 100644 --- a/Sources/OpenGestures/Node/GestureNode.swift +++ b/Sources/OpenGestures/Node/GestureNode.swift @@ -54,27 +54,23 @@ open class AnyGestureNode: Identifiable, @unchecked Sendable { // MARK: - Relations open var relations: [GestureRelation] { - relationMap.toRelations() + _openGesturesBaseClassAbstractMethod() } open func addRelation(_ relation: GestureRelation) { - relationMap.addRelation(relation) + _openGesturesBaseClassAbstractMethod() } - open func removeRelation(_ relation: GestureRelation) { - relationMap.removeRelation(relation) + open func addRelations(_ relations: [GestureRelation]) { + _openGesturesBaseClassAbstractMethod() } - open func addRelations(_ relations: [GestureRelation]) { - for relation in relations { - addRelation(relation) - } + open func removeRelation(_ relation: GestureRelation) { + _openGesturesBaseClassAbstractMethod() } open func removeRelations(_ relations: [GestureRelation]) { - for relation in relations { - removeRelation(relation) - } + _openGesturesBaseClassAbstractMethod() } // MARK: - Event Tracking @@ -200,7 +196,33 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { } public var latestPhase: GesturePhase { - phaseQueue.currentPhase + phaseQueue.pendingPhases.last ?? phase + } + + // MARK: - Relations + + public override var relations: [GestureRelation] { + relationMap.toRelations() + } + + public override func addRelation(_ relation: GestureRelation) { + relationMap.addRelation(relation) + } + + public override func addRelations(_ relations: [GestureRelation]) { + for relation in relations { + addRelation(relation) + } + } + + public override func removeRelation(_ relation: GestureRelation) { + relationMap.removeRelation(relation) + } + + public override func removeRelations(_ relations: [GestureRelation]) { + for relation in relations { + removeRelation(relation) + } } // MARK: - Update From 155dc1d85c262cc9422f46ca52a5b251ea339cbd Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 25 Apr 2026 00:12:09 +0800 Subject: [PATCH 07/14] Update relation code --- .../OpenGestures/Core/GestureRelation.swift | 29 +++++++++++++------ Sources/OpenGestures/Node/GestureNode.swift | 8 +++-- .../Node/GestureNodeContainer.swift | 18 ++++++++++-- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/Sources/OpenGestures/Core/GestureRelation.swift b/Sources/OpenGestures/Core/GestureRelation.swift index 3ff7e42..31c627a 100644 --- a/Sources/OpenGestures/Core/GestureRelation.swift +++ b/Sources/OpenGestures/Core/GestureRelation.swift @@ -75,33 +75,44 @@ package struct RelationMap: Sendable { self.relations = relations } - package mutating func add(_ definition: RelationDefinition, for matcher: GestureNodeMatcher) { - relations[matcher, default: []].insert(definition) + @discardableResult + package mutating func add(_ definition: RelationDefinition, for matcher: GestureNodeMatcher) -> Bool { + var set = relations[matcher] ?? [] + let (inserted, _) = set.insert(definition) + relations[matcher] = set + return inserted } - package mutating func remove(_ definition: RelationDefinition, for matcher: GestureNodeMatcher) { - relations[matcher]?.remove(definition) - if relations[matcher]?.isEmpty == true { + @discardableResult + package mutating func remove(_ definition: RelationDefinition, for matcher: GestureNodeMatcher) -> Bool { + guard var set = relations[matcher] else { return false } + let removed = set.remove(definition) != nil + if set.isEmpty { relations.removeValue(forKey: matcher) + } else { + relations[matcher] = set } + return removed } - package mutating func addRelation(_ relation: GestureRelation) { + @discardableResult + package mutating func addRelation(_ relation: GestureRelation) -> Bool { let definition = RelationDefinition( type: relation.type, direction: relation.direction, role: relation.role ) - add(definition, for: relation.target) + return add(definition, for: relation.target) } - package mutating func removeRelation(_ relation: GestureRelation) { + @discardableResult + package mutating func removeRelation(_ relation: GestureRelation) -> Bool { let definition = RelationDefinition( type: relation.type, direction: relation.direction, role: relation.role ) - remove(definition, for: relation.target) + return remove(definition, for: relation.target) } package func toRelations() -> [GestureRelation] { diff --git a/Sources/OpenGestures/Node/GestureNode.swift b/Sources/OpenGestures/Node/GestureNode.swift index a76a76f..1959e12 100644 --- a/Sources/OpenGestures/Node/GestureNode.swift +++ b/Sources/OpenGestures/Node/GestureNode.swift @@ -206,7 +206,9 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { } public override func addRelation(_ relation: GestureRelation) { - relationMap.addRelation(relation) + let changed = relationMap.addRelation(relation) + guard changed, listener != nil else { return } + // TODO: listener } public override func addRelations(_ relations: [GestureRelation]) { @@ -216,7 +218,9 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { } public override func removeRelation(_ relation: GestureRelation) { - relationMap.removeRelation(relation) + let changed = relationMap.removeRelation(relation) + guard changed, listener != nil else { return } + // TODO: listener } public override func removeRelations(_ relations: [GestureRelation]) { diff --git a/Sources/OpenGestures/Node/GestureNodeContainer.swift b/Sources/OpenGestures/Node/GestureNodeContainer.swift index 98f3125..e4633b0 100644 --- a/Sources/OpenGestures/Node/GestureNodeContainer.swift +++ b/Sources/OpenGestures/Node/GestureNodeContainer.swift @@ -3,7 +3,7 @@ // OpenGestures // // Audited for 9126.1.5 -// Status: WIP +// Status: Blocked by GestureNodeListener // MARK: - GestureNodeContainer @@ -17,7 +17,21 @@ public protocol GestureNodeContainer: AnyObject, Sendable { // MARK: - GestureNodeListener [WIP] package protocol GestureNodeListener: AnyObject { +// func gestureNode( +// _ node: AnyGestureNode, +// didAddRelation relation: GestureRelation, +// target matcher: GestureNodeMatcher +// ) +// +// func gestureNode( +// _ node: AnyGestureNode, +// didRemoveRelation relation: GestureRelation, +// target matcher: GestureNodeMatcher +// ) + // TODO } -// TODO: GestureNodeCoordinator: GestureNodeListener +extension GestureNodeCoordinator: GestureNodeListener { + // TODO +} From ef4713dbe83988d556d4bbdafa9a00b4b34f4f4a Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 25 Apr 2026 00:19:25 +0800 Subject: [PATCH 08/14] Update test folder --- .../{GestureNode => Node}/GestureNodeCompatibilityTests.swift | 0 .../{GestureNode => Node}/GestureNodeIDCompatibilityTests.swift | 0 .../GestureNodeOptionsCompatibilityTests.swift | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename Tests/OpenGesturesCompatibilityTests/{GestureNode => Node}/GestureNodeCompatibilityTests.swift (100%) rename Tests/OpenGesturesCompatibilityTests/{GestureNode => Node}/GestureNodeIDCompatibilityTests.swift (100%) rename Tests/OpenGesturesCompatibilityTests/{GestureNode => Node}/GestureNodeOptionsCompatibilityTests.swift (100%) diff --git a/Tests/OpenGesturesCompatibilityTests/GestureNode/GestureNodeCompatibilityTests.swift b/Tests/OpenGesturesCompatibilityTests/Node/GestureNodeCompatibilityTests.swift similarity index 100% rename from Tests/OpenGesturesCompatibilityTests/GestureNode/GestureNodeCompatibilityTests.swift rename to Tests/OpenGesturesCompatibilityTests/Node/GestureNodeCompatibilityTests.swift diff --git a/Tests/OpenGesturesCompatibilityTests/GestureNode/GestureNodeIDCompatibilityTests.swift b/Tests/OpenGesturesCompatibilityTests/Node/GestureNodeIDCompatibilityTests.swift similarity index 100% rename from Tests/OpenGesturesCompatibilityTests/GestureNode/GestureNodeIDCompatibilityTests.swift rename to Tests/OpenGesturesCompatibilityTests/Node/GestureNodeIDCompatibilityTests.swift diff --git a/Tests/OpenGesturesCompatibilityTests/GestureNode/GestureNodeOptionsCompatibilityTests.swift b/Tests/OpenGesturesCompatibilityTests/Node/GestureNodeOptionsCompatibilityTests.swift similarity index 100% rename from Tests/OpenGesturesCompatibilityTests/GestureNode/GestureNodeOptionsCompatibilityTests.swift rename to Tests/OpenGesturesCompatibilityTests/Node/GestureNodeOptionsCompatibilityTests.swift From 335334c2f801d92a387043aef1770d9597702ad2 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 25 Apr 2026 01:05:24 +0800 Subject: [PATCH 09/14] Update abort implementation --- Sources/OpenGestures/Node/GestureNode.swift | 30 ++++++++++----------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/Sources/OpenGestures/Node/GestureNode.swift b/Sources/OpenGestures/Node/GestureNode.swift index 1959e12..7558cfc 100644 --- a/Sources/OpenGestures/Node/GestureNode.swift +++ b/Sources/OpenGestures/Node/GestureNode.swift @@ -87,22 +87,22 @@ open class AnyGestureNode: Identifiable, @unchecked Sendable { } } - // MARK: - Update / Abort / Fail [WIP] + // MARK: - Update [WIP] open func update(someValue: T, isFinalUpdate: Bool) throws { _openGesturesBaseClassAbstractMethod() } - public final func abort() throws { - try _abort() + open func fail(with error: Error) throws { + _openGesturesBaseClassAbstractMethod() } - package func _abort() throws { + open func update(reason: GestureFailureReason, isFinalUpdate: Bool) throws { _openGesturesBaseClassAbstractMethod() } - open func fail(with error: Error) throws { - _openGesturesBaseClassAbstractMethod() + public final func abort() throws { + try update(reason: .aborted, isFinalUpdate: true) } // MARK: - Debug @@ -229,9 +229,10 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { } } - // MARK: - Update + // MARK: - Update [WIP] public func update(value: Value, isFinalUpdate: Bool) throws { + // FIXME let oldPhase = phaseQueue.currentPhase let newPhase: GesturePhase = isFinalUpdate ? .ended(value: value) : .active(value: value) phaseQueue.currentPhase = newPhase @@ -239,28 +240,25 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { } public override func update(someValue: T, isFinalUpdate: Bool) throws { + // FIXME guard let typedValue = someValue as? Value else { fatalError("Type mismatch: expected \(Value.self), got \(type(of: someValue))") } try update(value: typedValue, isFinalUpdate: isFinalUpdate) } - // MARK: - Abort / Fail - - package override func _abort() throws { - let oldPhase = phaseQueue.currentPhase - let newPhase: GesturePhase = .failed(reason: .aborted) - phaseQueue.currentPhase = newPhase - delegate?.gestureNode(self, didUpdatePhase: newPhase, oldPhase: oldPhase) - } - public override func fail(with error: Error) throws { + // FIXME let oldPhase = phaseQueue.currentPhase let newPhase: GesturePhase = .failed(reason: .custom(error)) phaseQueue.currentPhase = newPhase delegate?.gestureNode(self, didUpdatePhase: newPhase, oldPhase: oldPhase) } + public override func update(reason: GestureFailureReason, isFinalUpdate: Bool) throws { + // TODO + } + // MARK: - Debug // FIXME From f1985c45be9d05c3018c30154f8bf2a5e7faf469 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 25 Apr 2026 01:10:24 +0800 Subject: [PATCH 10/14] Update fail implementation --- Sources/OpenGestures/Node/GestureNode.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Sources/OpenGestures/Node/GestureNode.swift b/Sources/OpenGestures/Node/GestureNode.swift index 7558cfc..2eaa01f 100644 --- a/Sources/OpenGestures/Node/GestureNode.swift +++ b/Sources/OpenGestures/Node/GestureNode.swift @@ -248,11 +248,7 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { } public override func fail(with error: Error) throws { - // FIXME - let oldPhase = phaseQueue.currentPhase - let newPhase: GesturePhase = .failed(reason: .custom(error)) - phaseQueue.currentPhase = newPhase - delegate?.gestureNode(self, didUpdatePhase: newPhase, oldPhase: oldPhase) + try update(reason: .custom(error), isFinalUpdate: false) } public override func update(reason: GestureFailureReason, isFinalUpdate: Bool) throws { From 7e7a0772739ef2582dd10572ce226c8a6dcfc33a Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 25 Apr 2026 01:32:08 +0800 Subject: [PATCH 11/14] Update GestureNode --- Sources/OpenGestures/Node/GestureNode.swift | 29 ++++++++++++++++++- .../Node/GestureNodeOptions.swift | 8 +++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/Sources/OpenGestures/Node/GestureNode.swift b/Sources/OpenGestures/Node/GestureNode.swift index 2eaa01f..b319c48 100644 --- a/Sources/OpenGestures/Node/GestureNode.swift +++ b/Sources/OpenGestures/Node/GestureNode.swift @@ -171,7 +171,7 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { public weak var delegate: (any GestureNodeDelegate)? - package var phaseQueue: GesturePhaseQueue + private var phaseQueue: GesturePhaseQueue // MARK: - Init @@ -189,6 +189,33 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { self.init(traits: nil, tag: nil, relations: .default) } + public override var options: GestureNodeOptions { + get { super.options } + set { + let oldValue = super.options + super.options = newValue + didChangeOptions(from: oldValue) + } + } + + private func didChangeOptions(from oldOptions: GestureNodeOptions) { + guard options.contains(.isDisabled) != oldOptions.contains(.isDisabled) else { return } + try? update(reason: .disabled, isFinalUpdate: true) + } + + public override var container: (any GestureNodeContainer)? { + get { super.container } + set { + super.container = newValue + didChangeContainer() + } + } + + private func didChangeContainer() { + guard container == nil else { return } + try? update(reason: .removedFromContainer, isFinalUpdate: true) + } + // MARK: - Phase public var phase: GesturePhase { diff --git a/Sources/OpenGestures/Node/GestureNodeOptions.swift b/Sources/OpenGestures/Node/GestureNodeOptions.swift index d46b7bc..214aebb 100644 --- a/Sources/OpenGestures/Node/GestureNodeOptions.swift +++ b/Sources/OpenGestures/Node/GestureNodeOptions.swift @@ -14,9 +14,11 @@ public struct GestureNodeOptions: OptionSet, Sendable { self.rawValue = rawValue } - public static let isDisabled = GestureNodeOptions(rawValue: 0x1) - public static let disallowExclusionWithUnresolvedFailureRequirements = GestureNodeOptions(rawValue: 0x2) - public static let isGloballyScoped = GestureNodeOptions(rawValue: 0x4) + public static let isDisabled: GestureNodeOptions = .init(rawValue: 1 << 0) + + public static let disallowExclusionWithUnresolvedFailureRequirements: GestureNodeOptions = .init(rawValue: 1 << 1) + + public static let isGloballyScoped: GestureNodeOptions = .init(rawValue: 1 << 2) } // MARK: - GestureNodeOptions + CustomStringConvertible From 13e8079d40a2c920d1acc0884769b87401269b2e Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 2 May 2026 01:42:45 +0800 Subject: [PATCH 12/14] Add logging gate and interpose shim --- Sources/COpenGestures/interpose.c | 33 +++++++ Sources/OpenGestures/Log.swift | 137 ++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 Sources/COpenGestures/interpose.c create mode 100644 Sources/OpenGestures/Log.swift diff --git a/Sources/COpenGestures/interpose.c b/Sources/COpenGestures/interpose.c new file mode 100644 index 0000000..50dcff4 --- /dev/null +++ b/Sources/COpenGestures/interpose.c @@ -0,0 +1,33 @@ +// +// interpose.c +// OpenGestures + +#include +#include +#include + +#if OGF_TARGET_OS_DARWIN +extern bool os_variant_has_internal_diagnostics(const char *subsystem); +#endif + +bool ogf_variant_has_internal_diagnostics(const char *subsystem) { + if (strcmp(subsystem, "org.OpenSwiftUIProject.OpenGestures") == 0) { + return true; + } else if (strcmp(subsystem, "com.apple.Gestures") == 0) { + return true; + } else { + #if OGF_TARGET_OS_DARWIN + return os_variant_has_internal_diagnostics(subsystem); + #else + return false; + #endif + } +} + +#if OGF_TARGET_OS_DARWIN +#define DYLD_INTERPOSE(_replacement,_replacee) \ + __attribute__((used)) static struct{ const void* replacement; const void* replacee; } _interpose_##_replacee \ + __attribute__ ((section ("__DATA,__interpose"))) = { (const void*)(unsigned long)&_replacement, (const void*)(unsigned long)&_replacee }; + +DYLD_INTERPOSE(ogf_variant_has_internal_diagnostics, os_variant_has_internal_diagnostics) +#endif diff --git a/Sources/OpenGestures/Log.swift b/Sources/OpenGestures/Log.swift new file mode 100644 index 0000000..732ed3b --- /dev/null +++ b/Sources/OpenGestures/Log.swift @@ -0,0 +1,137 @@ +// +// Log.swift +// OpenGestures + +import Foundation +import Synchronization +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +#if canImport(os) +package import os +#endif + +@_silgen_name("ogf_variant_has_internal_diagnostics") +private func ogf_variant_has_internal_diagnostics(_ subsystem: UnsafePointer) -> Bool + +#if canImport(Darwin) +private func logPreferencesChangedCallback( + _ center: CFNotificationCenter?, + _ observer: UnsafeMutableRawPointer?, + _ name: CFNotificationName?, + _ object: UnsafeRawPointer?, + _ userInfo: CFDictionary? +) { + Log.invalidateLoggingPreferencesCache() +} +#endif + +// 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 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) } + }() + + package static let isEnvironmentLoggingEnabled: Bool = { + guard let value = getenv("GESTURES_LOGGING_ENABLED") else { + return false + } + return atoi(value) != 0 + }() + + package static var isGesturesLoggingEnabled: Bool { + guard hasInternalContent else { + return false + } + if isEnvironmentLoggingEnabled { + return true + } + switch defaultsCacheState.load(ordering: .acquiring) { + case unknownDefaultsCacheState: + break + case enabledDefaultsCacheState: + return true + case disabledDefaultsCacheState: + return false + default: + preconditionFailure("Invalid logging defaults cache state") + } + guard let defaults = UserDefaults(suiteName: subsystem) else { + return false + } + let isEnabled = defaults.bool(forKey: "LoggingEnabled") + defaultsCacheState.store( + isEnabled ? enabledDefaultsCacheState : disabledDefaultsCacheState, + ordering: .releasing + ) + registerLoggingPreferencesObserver() + return isEnabled + } + + fileprivate static func invalidateLoggingPreferencesCache() { + defaultsCacheState.store(unknownDefaultsCacheState, ordering: .releasing) + } + + private static func registerLoggingPreferencesObserver() { + #if canImport(Darwin) + let result = observerRegistered.compareExchange( + expected: 0, + desired: 1, + ordering: .acquiringAndReleasing + ) + guard result.exchanged else { + return + } + let center = CFNotificationCenterGetDarwinNotifyCenter() + let notificationName = "\(subsystem).LoggingPreferences" as CFString + CFNotificationCenterAddObserver( + center, + nil, + logPreferencesChangedCallback, + notificationName, + nil, + .deliverImmediately + ) + #endif + } + + #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) + + 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 func logEnqueuedPhase(_ node: AnyGestureNode) { + enabledLogger(for: .nodes).log("\(node.debugLabel, privacy: .public) enqueued phase") + } + #else + package static func logEnqueuedPhase(_ node: AnyGestureNode) {} + #endif +} From ad5bc942aafe4b40d38d61deed987d03c3a36a31 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 2 May 2026 01:59:53 +0800 Subject: [PATCH 13/14] Update GestureNode.update API --- .../OpenGestures/Core/GesturePhaseQueue.swift | 53 ++++++++++++++ Sources/OpenGestures/Node/GestureNode.swift | 69 +++++++++++++++---- .../Node/GestureNodeContainer.swift | 8 ++- .../Node/GestureNodeCoordinator.swift | 31 ++++++++- .../Node/GestureNodeCompatibilityTests.swift | 2 +- .../Core/GesturePhaseQueueTests.swift | 61 +++++++++++++++- 6 files changed, 204 insertions(+), 20 deletions(-) diff --git a/Sources/OpenGestures/Core/GesturePhaseQueue.swift b/Sources/OpenGestures/Core/GesturePhaseQueue.swift index 77d12b3..495aa4e 100644 --- a/Sources/OpenGestures/Core/GesturePhaseQueue.swift +++ b/Sources/OpenGestures/Core/GesturePhaseQueue.swift @@ -22,6 +22,28 @@ package struct GesturePhaseQueue { self.currentPhase = currentPhase self.pendingPhases = pendingPhases } + + package var latestPhase: GesturePhase { + pendingPhases.last ?? currentPhase + } + + // TBA + package mutating func enqueue(_ phase: GesturePhase) throws { + let latestPhase = latestPhase + guard latestPhase.canTransition(to: phase) else { + throw InvalidTransition(phase: latestPhase, targetPhase: phase) + } + pendingPhases.append(phase) + } + + // TBA + package mutating func processNextPhase() -> (oldPhase: GesturePhase, newPhase: GesturePhase)? { + guard !pendingPhases.isEmpty else { return nil } + let oldPhase = currentPhase + let newPhase = pendingPhases.removeFirst() + currentPhase = newPhase + return (oldPhase, newPhase) + } } // MARK: - GesturePhaseQueue.InvalidTransition @@ -39,3 +61,34 @@ extension GesturePhaseQueue { } extension GesturePhaseQueue.InvalidTransition: NestedCustomStringConvertible {} + +// MARK: - GesturePhase Transition Rules + +extension GesturePhase { + // TBA + package func canTransition(to targetPhase: GesturePhase) -> Bool { + switch (self, targetPhase) { + case (.idle, .possible): + true + case (.possible, .blocked), + (.possible, .active), + (.possible, .ended), + (.possible, .failed): + true + case (.blocked, .blocked), + (.blocked, .active), + (.blocked, .ended), + (.blocked, .failed): + true + case (.active, .active), + (.active, .ended), + (.active, .failed): + true + case (.ended, .idle), + (.failed, .idle): + true + default: + false + } + } +} diff --git a/Sources/OpenGestures/Node/GestureNode.swift b/Sources/OpenGestures/Node/GestureNode.swift index b319c48..e822476 100644 --- a/Sources/OpenGestures/Node/GestureNode.swift +++ b/Sources/OpenGestures/Node/GestureNode.swift @@ -97,7 +97,12 @@ open class AnyGestureNode: Identifiable, @unchecked Sendable { _openGesturesBaseClassAbstractMethod() } - open func update(reason: GestureFailureReason, isFinalUpdate: Bool) throws { + package func update(reason: GestureFailureReason, isFinalUpdate: Bool) throws { + _openGesturesBaseClassAbstractMethod() + } + + @discardableResult + package func processPendingPhaseUpdates() -> Bool { _openGesturesBaseClassAbstractMethod() } @@ -223,7 +228,7 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { } public var latestPhase: GesturePhase { - phaseQueue.pendingPhases.last ?? phase + phaseQueue.latestPhase } // MARK: - Relations @@ -256,30 +261,64 @@ public class GestureNode: AnyGestureNode, @unchecked Sendable { } } - // MARK: - Update [WIP] + // MARK: - Update public func update(value: Value, isFinalUpdate: Bool) throws { - // FIXME - let oldPhase = phaseQueue.currentPhase - let newPhase: GesturePhase = isFinalUpdate ? .ended(value: value) : .active(value: value) - phaseQueue.currentPhase = newPhase - delegate?.gestureNode(self, didUpdatePhase: newPhase, oldPhase: oldPhase) + try update( + value: value, + isFinalUpdate: isFinalUpdate, + synchronously: false + ) } public override func update(someValue: T, isFinalUpdate: Bool) throws { - // FIXME - guard let typedValue = someValue as? Value else { - fatalError("Type mismatch: expected \(Value.self), got \(type(of: someValue))") - } - try update(value: typedValue, isFinalUpdate: isFinalUpdate) + try update( + value: unsafeBitCast(someValue, to: Value.self), + isFinalUpdate: isFinalUpdate, + synchronously: false + ) + } + + private func update(value: Value, isFinalUpdate: Bool, synchronously: Bool) throws { + let newPhase: GesturePhase = isFinalUpdate ? .ended(value: value) : .active(value: value) + try enqueuePhase(newPhase, synchronousUpdate: synchronously) } public override func fail(with error: Error) throws { try update(reason: .custom(error), isFinalUpdate: false) } - public override func update(reason: GestureFailureReason, isFinalUpdate: Bool) throws { - // TODO + package override func update(reason: GestureFailureReason, isFinalUpdate: Bool) throws { + try enqueuePhase(.failed(reason: reason), synchronousUpdate: isFinalUpdate) + } + + private func enqueuePhase(_ phase: GesturePhase, synchronousUpdate: Bool) throws { + var phase = phase + if let delegate, phase.isRecognized, phaseQueue.latestPhase.isPossible { + let shouldActivate = delegate.gestureNodeShouldActivate(self) + if !shouldActivate { + phase = .failed(reason: .activationDenied) + } + } + try phaseQueue.enqueue(phase) + Log.logEnqueuedPhase(self) + listener?.gestureNode(self, didEnqueuePhaseWithSynchronousUpdate: synchronousUpdate) // TBA + delegate?.gestureNode(self, didEnqueuePhase: phase) + } + + // TBA + @discardableResult + package override func processPendingPhaseUpdates() -> Bool { + var didProcess = false + while let transition = phaseQueue.processNextPhase() { + delegate?.gestureNode( + self, + didUpdatePhase: transition.newPhase, + oldPhase: transition.oldPhase + ) + didProcess = true + } + return didProcess } // MARK: - Debug diff --git a/Sources/OpenGestures/Node/GestureNodeContainer.swift b/Sources/OpenGestures/Node/GestureNodeContainer.swift index e4633b0..1b4872a 100644 --- a/Sources/OpenGestures/Node/GestureNodeContainer.swift +++ b/Sources/OpenGestures/Node/GestureNodeContainer.swift @@ -29,9 +29,13 @@ package protocol GestureNodeListener: AnyObject { // target matcher: GestureNodeMatcher // ) - // TODO + func gestureNode( + _ node: AnyGestureNode, + didEnqueuePhaseWithSynchronousUpdate synchronous: Bool + ) + + // func syncPhaseChange(for node: AnyGestureNode) } extension GestureNodeCoordinator: GestureNodeListener { - // TODO } diff --git a/Sources/OpenGestures/Node/GestureNodeCoordinator.swift b/Sources/OpenGestures/Node/GestureNodeCoordinator.swift index 96734ad..6135b2a 100644 --- a/Sources/OpenGestures/Node/GestureNodeCoordinator.swift +++ b/Sources/OpenGestures/Node/GestureNodeCoordinator.swift @@ -67,8 +67,37 @@ public final class GestureNodeCoordinator: NSObject, @unchecked Sendable { } } - public func processUpdates(reason: String) { +// public func processUpdates(reason: String) { +// guard !nodesNeedingUpdate.isEmpty, !isProcessingUpdates else { return } +// isProcessingUpdates = true +// defer { +// synchronousNodeUpdates.removeAll() +// isProcessingUpdates = false +// } +// willProcessUpdateQueue?() +// while !nodesNeedingUpdate.isEmpty { +// let pendingNodes = nodesNeedingUpdate.sorted() +// nodesNeedingUpdate.removeAll() +// for node in pendingNodes { +// node.processPendingPhaseUpdates() +// } +// } +// didUpdate?() +// } + + package func gestureNode( + _ node: AnyGestureNode, + didEnqueuePhaseWithSynchronousUpdate synchronous: Bool + ) { + nodesNeedingUpdate.insert(node) + guard synchronous else { return } // TODO + // synchronousNodeUpdates.append(node.id) + // syncPhaseChange(for: node) + } + + package func syncPhaseChange(for node: AnyGestureNode) { + // processUpdates(reason: "syncPhaseChange(for: \(node.id))") } } diff --git a/Tests/OpenGesturesCompatibilityTests/Node/GestureNodeCompatibilityTests.swift b/Tests/OpenGesturesCompatibilityTests/Node/GestureNodeCompatibilityTests.swift index 1c45c18..998cfa3 100644 --- a/Tests/OpenGesturesCompatibilityTests/Node/GestureNodeCompatibilityTests.swift +++ b/Tests/OpenGesturesCompatibilityTests/Node/GestureNodeCompatibilityTests.swift @@ -17,7 +17,7 @@ struct GestureNodeCompatibilityTests { // try node.update(someValue: 3, isFinalUpdate: false) let label = node.debugLabel - let regex = #/\: 0x[0-9a-f]+ "test"; id = \d+; phase = idle>/# + let regex = #/\: 0x[0-9a-f]+ "test"; id = \d+; phase = idle>/# #expect(label.wholeMatch(of: regex) != nil, "\(label) does not match regex") } diff --git a/Tests/OpenGesturesTests/Core/GesturePhaseQueueTests.swift b/Tests/OpenGesturesTests/Core/GesturePhaseQueueTests.swift index 748a6e7..f2bb695 100644 --- a/Tests/OpenGesturesTests/Core/GesturePhaseQueueTests.swift +++ b/Tests/OpenGesturesTests/Core/GesturePhaseQueueTests.swift @@ -48,6 +48,66 @@ struct GesturePhaseQueueTests { #expect(queue.pendingPhases.count == 1) } + @Test + func testTransitionRules() { + let firstNode = GestureNodeID(rawValue: 2) + let secondNode = GestureNodeID(rawValue: 3) + #expect(GesturePhase.idle.canTransition(to: .possible)) + #expect(GesturePhase.possible.canTransition(to: .blocked(value: 1, blockedBy: firstNode))) + #expect(GesturePhase.possible.canTransition(to: .active(value: 1))) + #expect(GesturePhase.possible.canTransition(to: .ended(value: 1))) + #expect(GesturePhase.possible.canTransition(to: .failed(reason: .aborted))) + #expect(GesturePhase.blocked(value: 1, blockedBy: firstNode).canTransition(to: .blocked(value: 2, blockedBy: secondNode))) + #expect(GesturePhase.blocked(value: 1, blockedBy: firstNode).canTransition(to: .active(value: 2))) + #expect(GesturePhase.blocked(value: 1, blockedBy: firstNode).canTransition(to: .ended(value: 2))) + #expect(GesturePhase.blocked(value: 1, blockedBy: firstNode).canTransition(to: .failed(reason: .aborted))) + #expect(GesturePhase.active(value: 1).canTransition(to: .active(value: 2))) + #expect(GesturePhase.active(value: 1).canTransition(to: .ended(value: 2))) + #expect(GesturePhase.active(value: 1).canTransition(to: .failed(reason: .aborted))) + #expect(GesturePhase.ended(value: 1).canTransition(to: .idle)) + #expect(GesturePhase.failed(reason: .aborted).canTransition(to: .idle)) + #expect(!GesturePhase.idle.canTransition(to: .active(value: 1))) + #expect(!GesturePhase.active(value: 1).canTransition(to: .possible)) + } + + @Test + func testEnqueueUsesLatestPendingPhase() throws { + var queue = GesturePhaseQueue( + timeSource: nil, + currentPhase: .possible, + pendingPhases: RingBuffer(capacity: 5, emptyValue: .idle) + ) + try queue.enqueue(.active(value: 1)) + try queue.enqueue(.ended(value: 2)) + #expect(queue.latestPhase.isEnded == true) + + let firstTransition = queue.processNextPhase() + #expect(firstTransition?.oldPhase.isPossible == true) + #expect(firstTransition?.newPhase.isActive == true) + + let secondTransition = queue.processNextPhase() + #expect(secondTransition?.oldPhase.isActive == true) + #expect(secondTransition?.newPhase.isEnded == true) + } + + @Test + func testEnqueueRejectsInvalidTransition() { + var queue = GesturePhaseQueue( + timeSource: nil, + currentPhase: .idle, + pendingPhases: RingBuffer(capacity: 5, emptyValue: .idle) + ) + do { + try queue.enqueue(.active(value: 1)) + Issue.record("Expected invalid transition") + } catch let error as GesturePhaseQueue.InvalidTransition { + #expect(error.phase.isIdle == true) + #expect(error.targetPhase.isActive == true) + } catch { + Issue.record("Expected InvalidTransition, got \(error)") + } + } + // MARK: - InvalidTransition @Test @@ -82,4 +142,3 @@ struct GesturePhaseQueueTests { """#) } } - From 41570620fd89643a3f06f6a062de4a5e4762f19d Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 2 May 2026 02:33:10 +0800 Subject: [PATCH 14/14] Fix Linux build issue --- Sources/OpenGestures/Node/GestureNodeCoordinator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/OpenGestures/Node/GestureNodeCoordinator.swift b/Sources/OpenGestures/Node/GestureNodeCoordinator.swift index 6135b2a..d98649a 100644 --- a/Sources/OpenGestures/Node/GestureNodeCoordinator.swift +++ b/Sources/OpenGestures/Node/GestureNodeCoordinator.swift @@ -1,7 +1,8 @@ +public import Foundation + // MARK: - GestureNodeCoordinator /// Central coordinator that manages gesture node updates and conflict resolution. -@objc public final class GestureNodeCoordinator: NSObject, @unchecked Sendable { // MARK: - Callbacks