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/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() {} } diff --git a/Sources/OpenGestures/Core/GesturePhaseQueue.swift b/Sources/OpenGestures/Core/GesturePhaseQueue.swift index dd1ef31..495aa4e 100644 --- a/Sources/OpenGestures/Core/GesturePhaseQueue.swift +++ b/Sources/OpenGestures/Core/GesturePhaseQueue.swift @@ -14,14 +14,36 @@ 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 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 @@ -40,3 +62,33 @@ 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/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/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/AnyGestureNode.swift b/Sources/OpenGestures/GestureNode/AnyGestureNode.swift deleted file mode 100644 index f0a2306..0000000 --- a/Sources/OpenGestures/GestureNode/AnyGestureNode.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Foundation - -// MARK: - AnyGestureNode - -/// Type-erased base class for gesture nodes. -open class AnyGestureNode: Hashable, Identifiable, @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? - public var options: GestureNodeOptions - public weak var container: (any GestureNodeContainer)? - private var _trackedEventIDs: Set = [] - - // MARK: - Init - - public 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() - - public 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: Any, 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") - } - - /// Fails the gesture with an error. - open func fail(with error: Error) throws { - fatalError("Subclass must override") - } - - // MARK: - Debug - - public var debugLabel: String { - "\(type(of: self))(\(id))" - } - - // MARK: - Hashable / Equatable - - public static func == (lhs: AnyGestureNode, rhs: AnyGestureNode) -> Bool { - lhs.id == rhs.id - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - -// MARK: - Comparable - -extension AnyGestureNode: Comparable { - public static func < (lhs: AnyGestureNode, rhs: AnyGestureNode) -> Bool { - lhs.id < rhs.id - } -} - -// 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/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/GestureNodeContainer.swift b/Sources/OpenGestures/GestureNode/GestureNodeContainer.swift deleted file mode 100644 index 4754b02..0000000 --- a/Sources/OpenGestures/GestureNode/GestureNodeContainer.swift +++ /dev/null @@ -1,8 +0,0 @@ -// MARK: - GestureNodeContainer - -/// Protocol for querying node hierarchy in the view tree. -public protocol GestureNodeContainer: AnyObject, Sendable { - func index(of node: AnyGestureNode) -> Int? - func isDescendant(of container: any GestureNodeContainer, referenceNode: AnyGestureNode) -> Bool - func isDeeper(than container: any GestureNodeContainer, referenceNode: AnyGestureNode) -> Bool -} diff --git a/Sources/OpenGestures/GestureNode/GestureNodeCoordinator.swift b/Sources/OpenGestures/GestureNode/GestureNodeCoordinator.swift deleted file mode 100644 index e39d85e..0000000 --- a/Sources/OpenGestures/GestureNode/GestureNodeCoordinator.swift +++ /dev/null @@ -1,80 +0,0 @@ -// MARK: - GestureNodeCoordinator - -/// Central coordinator that manages gesture node updates and conflict resolution. -public final class GestureNodeCoordinator: @unchecked Sendable { - - // MARK: - Callbacks - - public var willUpdate: (() -> Void)? - public var willProcessUpdateQueue: (() -> Void)? - public var didUpdate: (() -> Void)? - - // MARK: - Internal State - - private let timeSource: any TimeSource - private let updateDriver: (any GestureUpdateDriver)? - private let shouldTrackTransitiveDependencies: Bool - private var nodes: Set = [] - private var _nodeRefs: [AnyGestureNode] = [] - - // MARK: - Init - - public init( - timeSource: any TimeSource, - 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 } - } - } - - // MARK: - Update Dispatch - - /// Enqueues updates for the given nodes. - public func enqueueUpdates( - nodes: [AnyGestureNode], - reason: String, - closure: (AnyGestureNode) -> Void - ) { - for node in nodes { - guard !node.options.contains(.isDisabled), - node.container != nil else { - continue - } - closure(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 - } - } -} diff --git a/Sources/OpenGestures/GestureNode/GestureNodeDelegate.swift b/Sources/OpenGestures/GestureNode/GestureNodeDelegate.swift deleted file mode 100644 index 8ecb02a..0000000 --- a/Sources/OpenGestures/GestureNode/GestureNodeDelegate.swift +++ /dev/null @@ -1,30 +0,0 @@ -// 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 gestureNode( - _ node: GestureNode, - roleForRelationType type: GestureRelationType, - direction: GestureRelationDirection, - relatedNode: AnyGestureNode - ) -> GestureRelationRole? -} - -// MARK: - Default implementations - -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( - _ node: GestureNode, - roleForRelationType type: GestureRelationType, - direction: GestureRelationDirection, - relatedNode: AnyGestureNode - ) -> GestureRelationRole? { nil } -} 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 +} diff --git a/Sources/OpenGestures/Node/GestureNode.swift b/Sources/OpenGestures/Node/GestureNode.swift new file mode 100644 index 0000000..e822476 --- /dev/null +++ b/Sources/OpenGestures/Node/GestureNode.swift @@ -0,0 +1,330 @@ +// +// GestureNode.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: WIP + +import Foundation +import Synchronization + +// MARK: - AnyGestureNode + +/// Type-erased base class for gesture nodes. +open class AnyGestureNode: Identifiable, @unchecked Sendable { + + // MARK: - Static ID counter + + 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 = GestureNodeID(rawValue: makeUniqueID()) + 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 = RelationMap() + package var trackedEvents: Set = [] + + // MARK: - Init + + package init( + traits: GestureTraitCollection? = nil, + tag: GestureTag? = nil, + relations: [GestureRelation] = [] + ) { + self.tag = tag + self.traits = traits + for relation in relations { + addRelation(relation) + } + } + + // MARK: - Relations + + open var relations: [GestureRelation] { + _openGesturesBaseClassAbstractMethod() + } + + open func addRelation(_ relation: GestureRelation) { + _openGesturesBaseClassAbstractMethod() + } + + open func addRelations(_ relations: [GestureRelation]) { + _openGesturesBaseClassAbstractMethod() + } + + open func removeRelation(_ relation: GestureRelation) { + _openGesturesBaseClassAbstractMethod() + } + + open func removeRelations(_ relations: [GestureRelation]) { + _openGesturesBaseClassAbstractMethod() + } + + // MARK: - Event Tracking + + public func startTrackingEvents(with eventIDs: [EventID]) { + for id in eventIDs { + trackedEvents.insert(id) + } + } + + public func stopTrackingEvents(with eventIDs: [EventID]) { + for id in eventIDs { + trackedEvents.remove(id) + } + } + + // MARK: - Update [WIP] + + open func update(someValue: T, isFinalUpdate: Bool) throws { + _openGesturesBaseClassAbstractMethod() + } + + open func fail(with error: Error) throws { + _openGesturesBaseClassAbstractMethod() + } + + package func update(reason: GestureFailureReason, isFinalUpdate: Bool) throws { + _openGesturesBaseClassAbstractMethod() + } + + @discardableResult + package func processPendingPhaseUpdates() -> Bool { + _openGesturesBaseClassAbstractMethod() + } + + public final func abort() throws { + try update(reason: .aborted, isFinalUpdate: true) + } + + // MARK: - Debug + + 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: Hashable { + 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 { + 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] + +public class GestureNode: AnyGestureNode, @unchecked Sendable { + + // MARK: - Stored Properties + + public weak var delegate: (any GestureNodeDelegate)? + + private var phaseQueue: GesturePhaseQueue + + // MARK: - Init + + public override init( + traits: GestureTraitCollection?, + tag: GestureTag?, + relations: [GestureRelation] + ) { + delegate = nil + phaseQueue = .init() + super.init(traits: traits, tag: tag, relations: relations) + } + + public convenience init() { + 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 { + phaseQueue.currentPhase + } + + public var latestPhase: GesturePhase { + phaseQueue.latestPhase + } + + // MARK: - Relations + + public override var relations: [GestureRelation] { + relationMap.toRelations() + } + + public override func addRelation(_ relation: GestureRelation) { + let changed = relationMap.addRelation(relation) + guard changed, listener != nil else { return } + // TODO: listener + } + + public override func addRelations(_ relations: [GestureRelation]) { + for relation in relations { + addRelation(relation) + } + } + + public override func removeRelation(_ relation: GestureRelation) { + let changed = relationMap.removeRelation(relation) + guard changed, listener != nil else { return } + // TODO: listener + } + + public override func removeRelations(_ relations: [GestureRelation]) { + for relation in relations { + removeRelation(relation) + } + } + + // MARK: - Update + + public func update(value: Value, isFinalUpdate: Bool) throws { + try update( + value: value, + isFinalUpdate: isFinalUpdate, + synchronously: false + ) + } + + public override func update(someValue: T, isFinalUpdate: Bool) throws { + 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) + } + + 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 + + // FIXME + package override func describePhaseQueue() -> String { + "\(phaseQueue.currentPhase)" + } +} diff --git a/Sources/OpenGestures/Node/GestureNodeContainer.swift b/Sources/OpenGestures/Node/GestureNodeContainer.swift new file mode 100644 index 0000000..1b4872a --- /dev/null +++ b/Sources/OpenGestures/Node/GestureNodeContainer.swift @@ -0,0 +1,41 @@ +// +// GestureNodeContainer.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Blocked by GestureNodeListener + +// MARK: - GestureNodeContainer + +/// Protocol for querying node hierarchy in the view tree. +public protocol GestureNodeContainer: AnyObject, Sendable { + func index(of node: AnyGestureNode) -> Int? + 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 { +// func gestureNode( +// _ node: AnyGestureNode, +// didAddRelation relation: GestureRelation, +// target matcher: GestureNodeMatcher +// ) +// +// func gestureNode( +// _ node: AnyGestureNode, +// didRemoveRelation relation: GestureRelation, +// target matcher: GestureNodeMatcher +// ) + + func gestureNode( + _ node: AnyGestureNode, + didEnqueuePhaseWithSynchronousUpdate synchronous: Bool + ) + + // func syncPhaseChange(for node: AnyGestureNode) +} + +extension GestureNodeCoordinator: GestureNodeListener { +} diff --git a/Sources/OpenGestures/Node/GestureNodeCoordinator.swift b/Sources/OpenGestures/Node/GestureNodeCoordinator.swift new file mode 100644 index 0000000..d98649a --- /dev/null +++ b/Sources/OpenGestures/Node/GestureNodeCoordinator.swift @@ -0,0 +1,107 @@ +public import Foundation + +// MARK: - GestureNodeCoordinator + +/// Central coordinator that manages gesture node updates and conflict resolution. +public final class GestureNodeCoordinator: NSObject, @unchecked Sendable { + + // MARK: - Callbacks + + public var willUpdate: (() -> Void)? + public var willProcessUpdateQueue: (() -> Void)? + public var didUpdate: (() -> Void)? + + // MARK: - Tracked Nodes + + /// All nodes this coordinator currently owns. + private var nodes: Set = [] + + // MARK: - Configuration + + private let timeSource: any TimeSource + + // 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 + + public init( + timeSource: any TimeSource, + updateDriver: (any GestureUpdateDriver)? = nil, + shouldTrackTransitiveDependencies: Bool = false + ) { + // TODO + fatalError("TODO") + } + + // MARK: - Update Dispatch + + 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) + } + } + +// 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))") + } +} + +// TODO: SubgraphResetTracker + +struct SubgraphResetTracker {} diff --git a/Sources/OpenGestures/Node/GestureNodeDelegate.swift b/Sources/OpenGestures/Node/GestureNodeDelegate.swift new file mode 100644 index 0000000..e9b8310 --- /dev/null +++ b/Sources/OpenGestures/Node/GestureNodeDelegate.swift @@ -0,0 +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 gestureNode( + _ node: GestureNode, + roleForRelationType type: GestureRelationType, + direction: GestureRelationDirection, + relatedNode: AnyGestureNode + ) -> GestureRelationRole? +} 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 82% rename from Sources/OpenGestures/GestureNode/GestureNodeOptions.swift rename to Sources/OpenGestures/Node/GestureNodeOptions.swift index d46b7bc..214aebb 100644 --- a/Sources/OpenGestures/GestureNode/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 diff --git a/Tests/OpenGesturesCompatibilityTests/Node/GestureNodeCompatibilityTests.swift b/Tests/OpenGesturesCompatibilityTests/Node/GestureNodeCompatibilityTests.swift new file mode 100644 index 0000000..998cfa3 --- /dev/null +++ b/Tests/OpenGesturesCompatibilityTests/Node/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") + } +} 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 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 { """#) } } -