diff --git a/Sources/OpenGestures/ConflictResolution/GesturePhaseQueue.swift b/Sources/OpenGestures/ConflictResolution/GesturePhaseQueue.swift deleted file mode 100644 index 7c65007..0000000 --- a/Sources/OpenGestures/ConflictResolution/GesturePhaseQueue.swift +++ /dev/null @@ -1,24 +0,0 @@ -// MARK: - GesturePhaseQueue - -/// Manages the queue of gesture phase transitions. -/// -/// Ensures phase changes are processed in the correct order -/// during the coordinator's update cycle. -public struct GesturePhaseQueue: Sendable { - - private var entries: [(nodeID: GestureNodeID, phaseTag: Int)] = [] - - public init() {} - - mutating func enqueue(nodeID: GestureNodeID, phaseTag: Int) { - entries.append((nodeID, phaseTag)) - } - - mutating func dequeueAll() -> [(nodeID: GestureNodeID, phaseTag: Int)] { - let result = entries - entries.removeAll() - return result - } - - var isEmpty: Bool { entries.isEmpty } -} diff --git a/Sources/OpenGestures/Core/GesturePhaseQueue.swift b/Sources/OpenGestures/Core/GesturePhaseQueue.swift new file mode 100644 index 0000000..dd1ef31 --- /dev/null +++ b/Sources/OpenGestures/Core/GesturePhaseQueue.swift @@ -0,0 +1,42 @@ +// +// GesturePhaseQueue.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - GesturePhaseQueue + +/// A queue of gesture phase transitions. +package struct GesturePhaseQueue { + package var timeSource: (any TimeSource)? + package var currentPhase: GesturePhase + package var pendingPhases: RingBuffer> + + package init( + timeSource: (any TimeSource)?, + currentPhase: GesturePhase, + pendingPhases: RingBuffer> + ) { + self.timeSource = timeSource + self.currentPhase = currentPhase + self.pendingPhases = pendingPhases + } +} + +// MARK: - GesturePhaseQueue.InvalidTransition + +extension GesturePhaseQueue { + package struct InvalidTransition: Error { + package var phase: GesturePhase + package var targetPhase: GesturePhase + + package init(phase: GesturePhase, targetPhase: GesturePhase) { + self.phase = phase + self.targetPhase = targetPhase + } + } +} + +extension GesturePhaseQueue.InvalidTransition: NestedCustomStringConvertible {} + diff --git a/Sources/OpenGestures/GestureNode/GestureNode.swift b/Sources/OpenGestures/GestureNode/GestureNode.swift index 2a8f6a7..c654d0b 100644 --- a/Sources/OpenGestures/GestureNode/GestureNode.swift +++ b/Sources/OpenGestures/GestureNode/GestureNode.swift @@ -11,8 +11,11 @@ public final class GestureNode: AnyGestureNode, @unchecked Send private var _didUpdatePhase: ((GesturePhase, GesturePhase) -> Void)? private var _shouldActivate: (() -> Bool)? - public private(set) var phase: GesturePhase = .idle - public private(set) var latestPhase: GesturePhase = .idle + package var phaseQueue: GesturePhaseQueue = GesturePhaseQueue( + timeSource: nil, + currentPhase: .idle, + pendingPhases: RingBuffer(capacity: 5, emptyValue: .idle) + ) // MARK: - Init @@ -39,14 +42,10 @@ public final class GestureNode: AnyGestureNode, @unchecked Send // MARK: - Update public func update(value: Value, isFinalUpdate: Bool) throws { - let oldPhase = phase - if isFinalUpdate { - phase = .ended(value: value) - } else { - phase = .active(value: value) - } - latestPhase = phase - _didUpdatePhase?(phase, oldPhase) + 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 { @@ -59,17 +58,17 @@ public final class GestureNode: AnyGestureNode, @unchecked Send // MARK: - Abort / Fail public override func abort() throws { - let oldPhase = phase - phase = .failed(reason:.aborted) - latestPhase = phase - _didUpdatePhase?(phase, oldPhase) + 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 = phase + let oldPhase = phaseQueue.currentPhase // TODO: .error(Error) case once non-Sendable handling resolved - phase = .failed(reason:.aborted) - latestPhase = phase - _didUpdatePhase?(phase, oldPhase) + let newPhase: GesturePhase = .failed(reason: .aborted) + phaseQueue.currentPhase = newPhase + _didUpdatePhase?(newPhase, oldPhase) } } diff --git a/Sources/OpenGestures/Util/RingBuffer.swift b/Sources/OpenGestures/Util/RingBuffer.swift new file mode 100644 index 0000000..f707e99 --- /dev/null +++ b/Sources/OpenGestures/Util/RingBuffer.swift @@ -0,0 +1,116 @@ +// +// RingBuffer.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - RingBuffer + +package struct RingBuffer { + package let capacity: Int + package var count: Int + package var storage: [Element] + package var emptyValue: Element + package var start: Int + package var end: Int + + package init(capacity: Int, emptyValue: Element) { + self.capacity = capacity + self.count = 0 + self.storage = Array(repeating: emptyValue, count: capacity) + self.emptyValue = emptyValue + self.start = 0 + self.end = 0 + } + + package var isEmpty: Bool { count == 0 } + + package var isFull: Bool { count == capacity } + + package mutating func append(_ element: Element) { + storage[end] = element + end = (end + 1) % capacity + if isFull { + start = (start + 1) % capacity + } else { + count += 1 + } + } + + @discardableResult + package mutating func removeFirst() -> Element { + let value = storage[start] + storage[start] = emptyValue + start = (start + 1) % capacity + count -= 1 + return value + } +} + +// MARK: - RingBuffer + Sequence + +extension RingBuffer: Sequence { + package func makeIterator() -> RingBufferIterator { + RingBufferIterator( + ringBuffer: self, + currentIndex: start, + elementsRemaining: count + ) + } +} + +// MARK: - RingBuffer + Collection + +extension RingBuffer: Collection { + package var startIndex: Int { 0 } + + package var endIndex: Int { count } + + package func index(after i: Int) -> Int { i + 1 } + + package subscript(position: Int) -> Element { + storage[(start + position) % capacity] + } +} + +// MARK: - RingBuffer + BidirectionalCollection + +extension RingBuffer: BidirectionalCollection { + package func index(before i: Int) -> Int { i - 1 } +} + +// MARK: - RingBuffer + CustomStringConvertible + +extension RingBuffer: CustomStringConvertible { + package var description: String { + "[" + map { "\($0)" }.joined(separator: ", ") + "]" + } +} + +// MARK: - RingBufferIterator + +package struct RingBufferIterator: IteratorProtocol { + package let ringBuffer: RingBuffer + package var currentIndex: Int + package var elementsRemaining: Int + + package init( + ringBuffer: RingBuffer, + currentIndex: Int, + elementsRemaining: Int + ) { + self.ringBuffer = ringBuffer + self.currentIndex = currentIndex + self.elementsRemaining = elementsRemaining + } + + package mutating func next() -> Element? { + guard elementsRemaining > 0 else { return nil } + let value = ringBuffer.storage[currentIndex] + currentIndex = (currentIndex + 1) % ringBuffer.capacity + elementsRemaining -= 1 + return value + } +} + diff --git a/Tests/OpenGesturesTests/Core/GesturePhaseQueueTests.swift b/Tests/OpenGesturesTests/Core/GesturePhaseQueueTests.swift new file mode 100644 index 0000000..748a6e7 --- /dev/null +++ b/Tests/OpenGesturesTests/Core/GesturePhaseQueueTests.swift @@ -0,0 +1,85 @@ +// +// GesturePhaseQueueTests.swift +// OpenGesturesTests + +@_spi(Private) import OpenGestures +import Testing + +// MARK: - GesturePhaseQueueTests + +@Suite +struct GesturePhaseQueueTests { + + // MARK: - Init + + @Test + func testInit() { + let queue = GesturePhaseQueue( + timeSource: nil, + currentPhase: .idle, + pendingPhases: RingBuffer(capacity: 5, emptyValue: .idle) + ) + #expect(queue.currentPhase.isIdle == true) + #expect(queue.pendingPhases.isEmpty == true) + #expect(queue.timeSource == nil) + } + + // MARK: - Properties + + @Test + func testCurrentPhaseUpdate() { + var queue = GesturePhaseQueue( + timeSource: nil, + currentPhase: .idle, + pendingPhases: RingBuffer(capacity: 5, emptyValue: .idle) + ) + queue.currentPhase = .active(value: 42) + #expect(queue.currentPhase.isActive == true) + } + + @Test + func testPendingPhasesAppend() { + var queue = GesturePhaseQueue( + timeSource: nil, + currentPhase: .idle, + pendingPhases: RingBuffer(capacity: 5, emptyValue: .idle) + ) + queue.pendingPhases.append(.active(value: 1)) + #expect(queue.pendingPhases.count == 1) + } + + // MARK: - InvalidTransition + + @Test + func testInvalidTransitionInit() { + let transition = GesturePhaseQueue.InvalidTransition( + phase: .idle, + targetPhase: .active(value: 1) + ) + #expect(transition.phase.isIdle == true) + #expect(transition.targetPhase.isActive == true) + } + + @Test + func testInvalidTransitionIsError() { + let _: any Error = GesturePhaseQueue.InvalidTransition( + phase: .idle, + targetPhase: .active(value: 1) + ) + } + + @Test + func testInvalidTransitionDescription() { + let transition = GesturePhaseQueue.InvalidTransition( + phase: .idle, + targetPhase: .active(value: 1) + ) + #expect(transition.description == #""" + InvalidTransition { \#("") + phase: idle + targetPhase: active + } + """#) + } +} + diff --git a/Tests/OpenGesturesTests/Util/RingBufferTests.swift b/Tests/OpenGesturesTests/Util/RingBufferTests.swift new file mode 100644 index 0000000..669de40 --- /dev/null +++ b/Tests/OpenGesturesTests/Util/RingBufferTests.swift @@ -0,0 +1,177 @@ +// +// RingBufferTests.swift +// OpenGesturesTests + +@_spi(Private) import OpenGestures +import Testing + +// MARK: - RingBufferTests + +@Suite +struct RingBufferTests { + + // MARK: - Init + + @Test + func testInit() { + let buffer = RingBuffer(capacity: 5, emptyValue: 0) + #expect(buffer.capacity == 5) + #expect(buffer.count == 0) + #expect(buffer.isEmpty == true) + #expect(buffer.isFull == false) + #expect(buffer.start == 0) + #expect(buffer.end == 0) + #expect(buffer.storage == [0, 0, 0, 0, 0]) + } + + // MARK: - Append + + @Test + func testAppendSingle() { + var buffer = RingBuffer(capacity: 5, emptyValue: 0) + buffer.append(1) + #expect(buffer.count == 1) + #expect(buffer.isEmpty == false) + #expect(buffer.start == 0) + #expect(buffer.end == 1) + } + + @Test + func testAppendMultiple() { + var buffer = RingBuffer(capacity: 5, emptyValue: 0) + buffer.append(1) + buffer.append(2) + buffer.append(3) + #expect(buffer.count == 3) + #expect(buffer[0] == 1) + #expect(buffer[1] == 2) + #expect(buffer[2] == 3) + } + + @Test + func testAppendOverflow() { + var buffer = RingBuffer(capacity: 5, emptyValue: 0) + for i in 1...6 { + buffer.append(i) + } + #expect(buffer.count == 5) + #expect(buffer.isFull == true) + #expect(buffer.start == 1) + #expect(buffer[0] == 2) + #expect(buffer[4] == 6) + } + + // MARK: - RemoveFirst + + @Test + func testRemoveFirst() { + var buffer = RingBuffer(capacity: 5, emptyValue: 0) + buffer.append(1) + buffer.append(2) + buffer.append(3) + let first = buffer.removeFirst() + #expect(first == 1) + #expect(buffer.count == 2) + } + + @Test + func testRemoveFirstClearsSlot() { + var buffer = RingBuffer(capacity: 5, emptyValue: 0) + buffer.append(1) + buffer.append(2) + buffer.append(3) + buffer.removeFirst() + #expect(buffer.storage[0] == 0) + } + + // MARK: - Collection + + @Test + func testStartIndexEndIndex() { + var buffer = RingBuffer(capacity: 5, emptyValue: 0) + buffer.append(1) + buffer.append(2) + buffer.append(3) + #expect(buffer.startIndex == 0) + #expect(buffer.endIndex == buffer.count) + } + + @Test + func testSubscript() { + var buffer = RingBuffer(capacity: 3, emptyValue: 0) + for i in 1...5 { + buffer.append(i) + } + #expect(buffer[0] == 3) + #expect(buffer[1] == 4) + #expect(buffer[2] == 5) + } + + @Test + func testIndexAfter() { + let buffer = RingBuffer(capacity: 5, emptyValue: 0) + #expect(buffer.index(after: 0) == 1) + } + + @Test + func testIndexBefore() { + let buffer = RingBuffer(capacity: 5, emptyValue: 0) + #expect(buffer.index(before: 2) == 1) + } + + // MARK: - Sequence / Iterator + + @Test + func testIterator() { + var buffer = RingBuffer(capacity: 5, emptyValue: 0) + buffer.append(1) + buffer.append(2) + buffer.append(3) + var collected: [Int] = [] + for element in buffer { + collected.append(element) + } + #expect(collected == [1, 2, 3]) + } + + @Test + func testIteratorAfterWrapAround() { + var buffer = RingBuffer(capacity: 3, emptyValue: 0) + for i in 1...5 { + buffer.append(i) + } + var collected: [Int] = [] + for element in buffer { + collected.append(element) + } + #expect(collected == [3, 4, 5]) + } + + @Test + func testMap() { + var buffer = RingBuffer(capacity: 5, emptyValue: 0) + buffer.append(1) + buffer.append(2) + buffer.append(3) + let doubled = buffer.map { $0 * 2 } + #expect(doubled == [2, 4, 6]) + } + + // MARK: - Description + + @Test + func testDescriptionEmpty() { + let buffer = RingBuffer(capacity: 5, emptyValue: 0) + #expect(buffer.description == "[]") + } + + @Test + func testDescriptionWithElements() { + var buffer = RingBuffer(capacity: 5, emptyValue: 0) + buffer.append(1) + buffer.append(2) + buffer.append(3) + #expect(buffer.description == "[1, 2, 3]") + } +} +