From 4a994b8581508c4556d4b7e5050f5a98ee0f5876 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 7 Sep 2026 05:03:29 +0800 Subject: [PATCH] feat: complete ViewGraph behavior --- .../View/Graph/ViewGraph.swift | 308 ++++++++---- .../View/Graph/ViewGraphFeature.swift | 4 + .../View/Graph/ViewGraphTests.swift | 454 +++++++++++++++++- 3 files changed, 664 insertions(+), 102 deletions(-) diff --git a/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift b/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift index 654189370..98c56797a 100644 --- a/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift +++ b/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift @@ -1,9 +1,9 @@ // // ViewGraph.swift -// OpenSwiftUI +// OpenSwiftUICore // -// Audited for 6.0.87 -// Status: WIP +// Audited for 6.5.4 +// Status: Complete // ID: D63C4EB7F2B205694B6515509E76E98B (SwiftUI) // ID: 7D9EDEF832940A362646A6E979F296C8 (SwiftUICore) @@ -15,22 +15,30 @@ package import Foundation #endif import OpenSwiftUI_SPI +// MARK: - ViewGraph + package final class ViewGraph: GraphHost { package struct Outputs: OptionSet { package let rawValue: UInt8 - + package init(rawValue: UInt8) { self.rawValue = rawValue } - + package static let displayList: ViewGraph.Outputs = .init(rawValue: 1 << 0) + package static let platformItemList: ViewGraph.Outputs = .init(rawValue: 1 << 1) + package static let viewResponders: ViewGraph.Outputs = .init(rawValue: 1 << 2) + package static let layout: ViewGraph.Outputs = .init(rawValue: 1 << 4) + package static let focus: ViewGraph.Outputs = .init(rawValue: 1 << 5) - package static let all: ViewGraph.Outputs = .init(rawValue: 0xFF) + + package static let all: ViewGraph.Outputs = .init(rawValue: .max) + package static let defaults: ViewGraph.Outputs = [.displayList, .viewResponders, .layout, .focus] - + @inline(__always) fileprivate func addRequestedPreferences(to inputs: inout _ViewInputs) { inputs.preferences.add(HostPreferencesKey.self) @@ -42,55 +50,80 @@ package final class ViewGraph: GraphHost { } } } - + let rootViewType: Any.Type + let makeRootView: (AnyAttribute, _ViewInputs) -> _ViewOutputs - + package weak var delegate: (any ViewGraphDelegate)? = nil - - private var features: ViewGraphFeatureBuffer = .init(contents: .init()) - + + private var features: ViewGraphFeatureBuffer = .init() + package var centersRootView: Bool = true - + package let rootView: AnyAttribute - + @Attribute var rootTransform: ViewTransform + @Attribute package var transform: ViewTransform + @Attribute package var zeroPoint: ViewOrigin + @Attribute package var proposedSize: ViewSize + @Attribute package var safeAreaInsets: _SafeAreaInsetsModifier - + @Attribute var rootGeometry: ViewGeometry + @Attribute var position: ViewOrigin + @Attribute var dimensions: ViewSize - + @OptionalAttribute var containerSize: ViewSize? - + @Attribute var gestureTime: Time + @Attribute var gestureEvents: [EventID : EventType] + @Attribute var inheritedPhase: _GestureInputs.InheritedPhase + @Attribute var gestureResetSeed: UInt32 + @OptionalAttribute var rootPhase: GesturePhase? + @OptionalAttribute package var gestureDebug: GestureDebug.Data? + @OptionalAttribute package var gestureCategory: GestureCategory? + @Attribute package var gesturePreferenceKeys: PreferenceKeys - var eventSubgraph: Subgraph? - + + private var eventSubgraph: Subgraph? + @Attribute package var defaultLayoutComputer: LayoutComputer + @WeakAttribute var rootResponders: [ViewResponder]? + @WeakAttribute var rootLayoutComputer: LayoutComputer? + @WeakAttribute var rootDisplayList: (DisplayList, DisplayList.Version)? - + package var sizeThatFitsObservers: ViewGraphGeometryObservers = .init() - + package var accessibilityEnabled: Bool = false - - package var requestedOutputs: Outputs + + package var requestedOutputs: Outputs { + didSet { + if oldValue != requestedOutputs { + uninstantiate() + } + } + } + var disabledOutputs: Outputs = [] - + private var mainUpdates: Int = 0 - - // MARK: - ViewGraph + NextUpdate [6.5.4] + + // MARK: - ViewGraph + NextUpdate package struct NextUpdate { package private(set) var time: Time = .infinity @@ -106,28 +139,28 @@ package final class ViewGraph: GraphHost { package private(set) var reasons: Set = [] package mutating func at(_ next: Time) { - time = next < time ? next : time + time.formMin(next) } - + package mutating func maxVelocity(_ velocity: CGFloat) { guard velocity >= 160 else { return } let interval = velocity < 320 ? 1 / 80.0 : 1 / 120.0 let highFrameRateReason: UInt32 = _HighFrameRateReasonMake(0) - var newInterval = min(interval, _interval) + var newInterval = min(_interval, interval) if _defaultIntervalWasRequested && newInterval > 1 / 60.0 { newInterval = .infinity } _interval = newInterval reasons.insert(highFrameRateReason) } - + package mutating func interval(_ interval: Double, reason: UInt32? = nil) { if interval == .zero { _defaultIntervalWasRequested = true } else { - _interval = min(interval, _interval) + _interval.formMin(interval) } if _defaultIntervalWasRequested && _interval > 1 / 60 { _interval = .infinity @@ -137,24 +170,28 @@ package final class ViewGraph: GraphHost { } } } - + package var nextUpdate: (views: NextUpdate, gestures: NextUpdate) = (NextUpdate(), NextUpdate()) - + private weak var _preferenceBridge: PreferenceBridge? - + package var preferenceBridge: PreferenceBridge? { get { _preferenceBridge } set { setPreferenceBridge(to: newValue) } } - var bridgedPreferences: [(any PreferenceKey.Type, AnyAttribute)] = [] + private var bridgedPreferences: [(any PreferenceKey.Type, AnyAttribute)] = [] package static var current: ViewGraph { GraphHost.currentHost as! ViewGraph } - - package init(rootViewType: Root.Type = Root.self, requestedOutputs: ViewGraph.Outputs = Outputs.defaults) where Root: View { + + package init( + rootViewType: Root.Type = Root.self, + requestedOutputs: ViewGraph.Outputs = Outputs.defaults + ) where Root: View { self.rootViewType = rootViewType self.requestedOutputs = requestedOutputs let data = GraphHost.Data() + let oldCurrent = Subgraph.current Subgraph.current = data.globalSubgraph rootView = Attribute(type: Root.self).identifier _rootTransform = Attribute(RootTransform()) @@ -195,7 +232,7 @@ package final class ViewGraph: GraphHost { } } super.init(data: data) - Subgraph.current = nil + Subgraph.current = oldCurrent } convenience init(rootView: Root, environment: EnvironmentValues) { @@ -205,26 +242,24 @@ package final class ViewGraph: GraphHost { } deinit { - // FIXME removePreferenceOutlets(isInvalidating: true) features.contents.destroy() } - + override public var graphDelegate: GraphDelegate? { delegate } - + override public var parentHost: GraphHost? { preferenceBridge?.viewGraph } package func append(feature: T) where T: ViewGraphFeature { features.append(feature) } - + package subscript(feature: T.Type) -> UnsafeMutablePointer? where T: ViewGraphFeature { features[feature] } override package func instantiateOutputs() { let outputs = rootSubgraph.apply { - // Audited for 6.5.4 var inputs = _ViewInputs( graphInputs, position: $position, @@ -283,7 +318,7 @@ package final class ViewGraph: GraphHost { hostPreferenceValues = WeakAttribute(outputs.preferences.hostPreferenceValues) makePreferenceOutlets(outputs: outputs) } - + override package func uninstantiateOutputs() { removePreferenceOutlets(isInvalidating: false) for feature in features { @@ -301,61 +336,100 @@ package final class ViewGraph: GraphHost { $rootDisplayList = nil hostPreferenceValues = WeakAttribute() } - + override package func timeDidChange() { nextUpdate.views = NextUpdate() } - + override package func isHiddenForReuseDidChange() { - _openSwiftUIUnimplementedFailure() + if let preferenceBridge { + if data.isHiddenForReuse { + for (key, value) in bridgedPreferences { + preferenceBridge.removeValue(value, for: key) + } + preferenceBridge.removeHostValues(for: data.$hostPreferenceKeys) + } else { + for (key, value) in bridgedPreferences { + preferenceBridge.addValue(value, for: key) + } + if hostPreferenceValues != WeakAttribute() { + preferenceBridge.addHostValues(hostPreferenceValues, for: data.$hostPreferenceKeys) + } + } + } + for feature in features { + feature.isHiddenForReuseDidChange(graph: self) + } + if isInstantiated || !data.isHiddenForReuse { + delegate?.graphDidChange() + } } - - private func makePreferenceOutlets(outputs: _ViewOutputs) { - // TODO + + package func requestImmediateUpdate() { + delegate?.requestUpdate(after: .zero) } - - @inline(__always) - private func removePreferenceOutlets(isInvalidating: Bool) { - // TODO + + private func makePreferenceOutlets(outputs: _ViewOutputs) { + guard let preferenceBridge else { + return + } + for key in preferenceBridge.requestedPreferences { + guard let value = outputs.preferences[anyKey: key] else { + continue + } + if !data.isHiddenForReuse { + preferenceBridge.addValue(value, for: key) + } + bridgedPreferences.append((key, value)) + } + if !data.isHiddenForReuse, let hostValues = outputs.preferences.hostPreferenceValues { + preferenceBridge.addHostValues(WeakAttribute(hostValues), for: data.$hostPreferenceKeys) + } } - // FIXME - package func updatePreferenceBridge( - environment: EnvironmentValues, - deferredUpdate: () -> Void - ) { - _openSwiftUIUnimplementedFailure() + private func removePreferenceOutlets(isInvalidating: Bool) { + guard let preferenceBridge else { + return + } + for (key, value) in bridgedPreferences { + preferenceBridge.removeValue(value, for: key, isInvalidating: isInvalidating) + } + bridgedPreferences = [] + preferenceBridge.removeHostValues(for: data.$hostPreferenceKeys, isInvalidating: isInvalidating) + preferenceBridge.removeChild(self) } } +// MARK: - ViewGraph + Root Inputs + extension ViewGraph { package func setRootView(_ view: Root) where Root: View { rootView.unsafeCast(to: Root.self).value = view } - + package func setSize(_ size: ViewSize) { let hasChange = $proposedSize.setValue(size) if hasChange { delegate?.graphDidChange() } } - + package func setProposedSize(_ size: CGSize) { let hasChange = $proposedSize.setValue(ViewSize.fixed(size)) if hasChange { delegate?.graphDidChange() } } - + package var size: ViewSize { proposedSize } - + @discardableResult package func setSafeAreaInsets(_ insets: EdgeInsets) -> Bool { setSafeAreaInsets([.init(regions: .container, insets: insets)]) } - + @discardableResult package func setSafeAreaInsets(_ elts: [SafeAreaInsets.Element]) -> Bool { let hasChange = $safeAreaInsets.setValue(.init(elements: elts)) @@ -364,7 +438,7 @@ extension ViewGraph { } return hasChange } - + package func setContainerSize(_ size: ViewSize) { guard let $containerSize else { return @@ -374,7 +448,7 @@ extension ViewGraph { delegate?.graphDidChange() } } - + @discardableResult package func invalidateTransform() -> Bool { let rootTransform = $rootTransform @@ -387,13 +461,13 @@ extension ViewGraph { } } -// MARK: - ViewGraph + Update [6.5.4] +// MARK: - ViewGraph + Update extension ViewGraph { package var updateRequiredMainThread: Bool { graph.mainUpdates != mainUpdates } - + package func updateOutputs(at time: Time) { beginNextUpdate(at: time) updateOutputs(async: false) @@ -424,11 +498,11 @@ extension ViewGraph { } return result } - + package func displayList() -> (DisplayList, DisplayList.Version) { $rootDisplayList?.value ?? (.init(), .init()) } - + private func beginNextUpdate(at time: Time) { setTime(time) data.updateSeed.unsafeIncrement() @@ -534,11 +608,9 @@ extension ViewGraph { } fileprivate var rootViewInsets: EdgeInsets { - guard !safeAreaInsets.elements.isEmpty else { - return .zero - } - // FIXME - return .zero + var insets = safeAreaInsets.elements.reduce(.zero) { $0 + $1.insets } + insets.xFlipIfRightToLeft { data.environment.layoutDirection } + return insets } static func sizeThatFits( @@ -547,12 +619,12 @@ extension ViewGraph { insets: EdgeInsets ) -> CGSize { var proposal = proposal - proposal.width = proposal.width.map { max($0 - insets.horizontal, .zero) } - proposal.height = proposal.width.map { max($0 - insets.vertical, .zero) } + proposal.width = proposal.width.map { max(.zero, $0 - insets.horizontal) } + proposal.height = proposal.height.map { max(.zero, $0 - insets.vertical) } let fittingSize = if let layoutComputer { layoutComputer.sizeThatFits(proposal) } else { - CGSize(width: 10.0, height: 10.0) + proposal.fixingUnspecifiedDimensions() } return fittingSize.outset(by: insets) } @@ -562,42 +634,76 @@ extension ViewGraph { } package func explicitAlignment(of guide: VerticalAlignment, at size: CGSize) -> CGFloat? { - _openSwiftUIUnimplementedFailure() + guard let layoutComputer else { + return nil + } + let insets = rootViewInsets + let size = ViewSize.fixed(size.inset(by: insets)) + return layoutComputer.explicitAlignment(guide.key, at: size).map { $0 + insets.top } } - + package func explicitAlignment(of guide: HorizontalAlignment, at size: CGSize) -> CGFloat? { - _openSwiftUIUnimplementedFailure() + guard let layoutComputer else { + return nil + } + let insets = rootViewInsets + let size = ViewSize.fixed(size.inset(by: insets)) + return layoutComputer.explicitAlignment(guide.key, at: size).map { $0 + insets.leading } } - + package func alignment(of guide: VerticalAlignment, at size: CGSize) -> CGFloat { - _openSwiftUIUnimplementedFailure() + let layoutComputer = layoutComputer ?? .defaultValue + let insets = rootViewInsets + let dimensions = ViewDimensions( + guideComputer: layoutComputer, + size: .fixed(size.inset(by: insets)) + ) + return dimensions[explicit: guide] ?? (insets.top + guide.key.id.defaultValue(in: dimensions)) } - + package func alignment(of guide: HorizontalAlignment, at size: CGSize) -> CGFloat { - _openSwiftUIUnimplementedFailure() + let layoutComputer = layoutComputer ?? .defaultValue + let insets = rootViewInsets + let dimensions = ViewDimensions( + guideComputer: layoutComputer, + size: .fixed(size.inset(by: insets)) + ) + return dimensions[explicit: guide] ?? (insets.leading + guide.key.id.defaultValue(in: dimensions)) } - + package func viewDebugData() -> [_ViewDebug.Data] { _ViewDebug.makeDebugData(subgraph: rootSubgraph) } } +// MARK: - ViewGraph + PreferenceBridge + extension ViewGraph { + package func updatePreferenceBridge( + environment: EnvironmentValues, + deferredUpdate: @escaping () -> Void + ) { + guard let preferenceBridge = environment.preferenceBridge, + preferenceBridge !== _preferenceBridge + else { + return + } + if GraphHost.isUpdating { + Update.enqueueAction(reason: nil) { + deferredUpdate() + } + } else { + self.preferenceBridge = preferenceBridge + } + } + package func invalidatePreferenceBridge() { setPreferenceBridge(to: nil, isInvalidating: true) } - - @inline(__always) + private func setPreferenceBridge(to preferenceBridge: PreferenceBridge?, isInvalidating: Bool = false) { guard _preferenceBridge !== preferenceBridge else { return } - if let preferenceBridge = _preferenceBridge { - for (src, key) in bridgedPreferences { - preferenceBridge.removeValue(key, for: src, isInvalidating: isInvalidating) - } - bridgedPreferences = [] - preferenceBridge.removeHostValues(for: data.$hostPreferenceKeys, isInvalidating: isInvalidating) - preferenceBridge.removeChild(self) - } + removePreferenceOutlets(isInvalidating: isInvalidating) _preferenceBridge = nil if isInstantiated { uninstantiate(immediately: isInvalidating) @@ -610,7 +716,7 @@ extension ViewGraph { } } -// MARK: - ViewGraph event lifecycle [6.5.4] +// MARK: - ViewGraph event lifecycle extension ViewGraph { package var responderNode: ResponderNode? { @@ -816,7 +922,7 @@ package struct RootGeometry: Rule, AsyncAttribute { extension Graph { package func viewGraph() -> ViewGraph { - unsafeBitCast(context, to: ViewGraph.self) + unsafeBitCast(context!, to: ViewGraph.self) } } @@ -836,11 +942,11 @@ private struct RootDisplayList: Rule, AsyncAttribute { var displayList = content let version = DisplayList.Version(forUpdate: ()) displayList.applyViewGraphTransform(time: $time, version: version) - return (content, version) + return (displayList, version) } } -// MARK: - RootTransform [6.5.4] +// MARK: - RootTransform private struct RootTransform: Rule { var value: ViewTransform { diff --git a/Sources/OpenSwiftUICore/View/Graph/ViewGraphFeature.swift b/Sources/OpenSwiftUICore/View/Graph/ViewGraphFeature.swift index ee2805280..16f4c5a49 100644 --- a/Sources/OpenSwiftUICore/View/Graph/ViewGraphFeature.swift +++ b/Sources/OpenSwiftUICore/View/Graph/ViewGraphFeature.swift @@ -27,6 +27,10 @@ extension ViewGraphFeature { } struct ViewGraphFeatureBuffer: Collection { + init(contents: UnsafeHeterogeneousBuffer = .init()) { + self.contents = contents + } + var contents: UnsafeHeterogeneousBuffer @discardableResult diff --git a/Tests/OpenSwiftUICoreTests/View/Graph/ViewGraphTests.swift b/Tests/OpenSwiftUICoreTests/View/Graph/ViewGraphTests.swift index 71a99c1d7..490a4965b 100644 --- a/Tests/OpenSwiftUICoreTests/View/Graph/ViewGraphTests.swift +++ b/Tests/OpenSwiftUICoreTests/View/Graph/ViewGraphTests.swift @@ -2,21 +2,340 @@ // ViewGraphTests.swift // OpenSwiftUICoreTests -@testable import OpenSwiftUICore +import Foundation +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) @testable import OpenSwiftUICore +import OpenSwiftUITestsSupport import OpenSwiftUI_SPI import Testing +@Suite(.tags(.aigc)) struct ViewGraphTests { + @Test(arguments: [ + (100, 60, CGSize(width: 100, height: 60)), + (nil, 60, CGSize(width: 22, height: 60)), + (100, nil, CGSize(width: 100, height: 18)), + (nil, nil, CGSize(width: 22, height: 18)), + (2, 3, CGSize(width: 12, height: 8)), + (.nan, 60, CGSize(width: 12, height: 60)), + (100, .nan, CGSize(width: 100, height: 8)), + (.nan, .nan, CGSize(width: 12, height: 8)), + ] as [(CGFloat?, CGFloat?, CGSize)]) + func sizeThatFitsPreservesProposalDimensions(width: CGFloat?, height: CGFloat?, expected: CGSize) { + Update.perform { + let insets = EdgeInsets(top: 3, leading: 5, bottom: 5, trailing: 7) + let proposal = _ProposedSize(width: width, height: height) + for layoutComputer: LayoutComputer? in [nil, .defaultValue] { + let result = ViewGraph.sizeThatFits(proposal, layoutComputer: layoutComputer, insets: insets) + #expect(result == expected) + } + } + } + + @MainActor + @Suite(.disabled(if: attributeGraphVendor == .oag)) + struct GraphTests { + @Test + func initializationRestoresCurrentSubgraph() { + Update.perform { + let parent = makeGraph() + let current = Subgraph.current + parent.globalSubgraph.apply { + let child = makeGraph() + #expect(Subgraph.current === parent.globalSubgraph) + #expect(child.graph.viewGraph() === child) + } + #expect(Subgraph.current === current) + } + } + + @Test + func requestedOutputsRebuildGraphOnlyWhenChanged() { + Update.perform { + let graph = makeGraph() + graph.append(feature: DisplayListFeature()) + graph.instantiate() + let root = graph.rootSubgraph + graph.requestedOutputs = [] + #expect(graph.isInstantiated) + #expect(graph.rootSubgraph === root) + #expect(graph.$rootDisplayList == nil) + + graph.requestedOutputs.insert(.displayList) + #expect(!graph.isInstantiated) + #expect(graph.rootSubgraph !== root) + graph.instantiate() + #expect(graph.$rootDisplayList != nil) + #expect(graph.displayList().0.items.count == 1) + } + } + + @Test(arguments: [false, true]) + func alignmentUsesSafeAreaInsets(rightToLeft: Bool) { + Update.perform { + let graph = makeGraph(outputs: .layout) + var environment = EnvironmentValues() + environment.layoutDirection = rightToLeft ? .rightToLeft : .leftToRight + graph.setEnvironment(environment) + graph.setSafeAreaInsets([ + .init(regions: .container, insets: .init(top: 3, leading: 5, bottom: 5, trailing: 7)), + .init(regions: .keyboard, insets: .init(top: 2, leading: 11, bottom: 4, trailing: 13)), + ]) + let size = CGSize(width: 100, height: 60) + #expect(graph.sizeThatFits(.unspecified) == CGSize(width: 46, height: 24)) + #expect(graph.explicitAlignment(of: HorizontalAlignment.center, at: size) == nil) + #expect(graph.explicitAlignment(of: VerticalAlignment.center, at: size) == nil) + #expect(graph.alignment(of: HorizontalAlignment.center, at: size) == (rightToLeft ? 52 : 48)) + #expect(graph.alignment(of: VerticalAlignment.center, at: size) == 28) + + graph.globalSubgraph.apply { + graph.$rootLayoutComputer = Attribute(value: LayoutComputer(AlignmentEngine())) + } + #expect(graph.explicitAlignment(of: HorizontalAlignment.center, at: size) == (rightToLeft ? 27 : 23)) + #expect(graph.explicitAlignment(of: VerticalAlignment.center, at: size) == 14) + #expect(graph.alignment(of: HorizontalAlignment.center, at: size) == 7) + #expect(graph.alignment(of: VerticalAlignment.center, at: size) == 9) + } + } + + @Test(arguments: [false, true]) + func preferenceOutletsFollowReuseAndTeardown(initiallyHidden: Bool) throws { + try Update.perform { + let parent = makeGraph() + let (bridge, value, hostValues) = try makeBridge(in: parent) + let child = makeGraph() + child.preferenceBridge = bridge + child.removedState = initiallyHidden ? .hiddenForReuse : [] + child.append(feature: PreferenceFeature()) + child.instantiate() + #expect(value.value == (initiallyHidden ? 0 : 42)) + #expect(hostValues.value[HostValueKey.self].value == (initiallyHidden ? 0 : 9)) + + child.removedState = [] + #expect(value.value == 42) + #expect(hostValues.value[HostValueKey.self].value == 9) + child.removedState = .hiddenForReuse + #expect(value.value == 0) + #expect(hostValues.value[HostValueKey.self].value == 0) + child.removedState = [] + #expect(value.value == 42) + #expect(hostValues.value[HostValueKey.self].value == 9) + + child.uninstantiate(immediately: true) + #expect(value.value == 0) + #expect(hostValues.value[HostValueKey.self].value == 0) + } + } + + @Test + func replacingPreferenceBridgeDisconnectsOldOutputs() throws { + try Update.perform { + let firstParent = makeGraph() + let secondParent = makeGraph() + let (first, firstValue, _) = try makeBridge(in: firstParent) + let (second, secondValue, _) = try makeBridge(in: secondParent) + let child = makeGraph() + child.preferenceBridge = first + child.append(feature: PreferenceFeature()) + child.instantiate() + #expect(firstValue.value == 42) + + child.preferenceBridge = second + #expect(!child.isInstantiated) + #expect(child.parentHost === secondParent) + #expect(firstValue.value == 0) + #expect(secondValue.value == 0) + child.instantiate() + #expect(secondValue.value == 42) + first.invalidate() + #expect(child.preferenceBridge === second) + child.invalidatePreferenceBridge() + #expect(secondValue.value == 0) + #expect(child.preferenceBridge == nil) + } + } + + @Test + func graphDeinitRemovesPreferenceOutletsAndChild() throws { + try Update.perform { + let parent = makeGraph() + let (bridge, value, hostValues) = try makeBridge(in: parent) + var child: ViewGraph? = makeGraph() + weak let weakChild = child + child?.preferenceBridge = bridge + child?.append(feature: PreferenceFeature()) + child?.instantiate() + #expect(value.value == 42) + child = nil + #expect(weakChild == nil) + #expect(value.value == 0) + #expect(hostValues.value[HostValueKey.self].value == 0) + bridge.removedStateDidChange() + bridge.invalidate() + } + } + + @Test + func environmentWithoutBridgePreservesExistingBridge() { + Update.perform { + let parent = makeGraph() + let bridge = parent.globalSubgraph.apply { PreferenceBridge() } + let child = makeGraph() + var environment = EnvironmentValues() + environment.preferenceBridge = bridge + child.updatePreferenceBridge(environment: environment) { + Issue.record("An idle graph must update the bridge immediately") + } + child.instantiate() + let root = child.rootSubgraph + child.updatePreferenceBridge(environment: environment) { + Issue.record("An unchanged bridge must not queue an update") + } + child.updatePreferenceBridge(environment: EnvironmentValues()) { + Issue.record("An absent bridge must not queue an update") + } + #expect(child.preferenceBridge === bridge) + #expect(child.rootSubgraph === root) + #expect(child.isInstantiated) + } + } + + @Test + func bridgeChangesDuringEvaluationAreDeferred() { + Update.perform { + let parent = makeGraph() + let bridge = parent.globalSubgraph.apply { PreferenceBridge() } + let child = makeGraph() + var environment = EnvironmentValues() + environment.preferenceBridge = bridge + var callbacks = 0 + let probe = child.globalSubgraph.apply { + Attribute(BridgeUpdate(graph: child, environment: environment) { + #expect(!GraphHost.isUpdating) + callbacks += 1 + child.updatePreferenceBridge(environment: environment) { + Issue.record("The deferred update must run outside graph evaluation") + } + }) + } + #expect(probe.value) + #expect(child.preferenceBridge == nil) + #expect(callbacks == 0) + Update.dispatchActions() + #expect(callbacks == 1) + #expect(child.preferenceBridge === bridge) + child.invalidate() + } + } + + @Test + func reuseNotifiesFeaturesAndDelegate() { + Update.perform { + let graph = makeGraph() + let delegate = Delegate(graph: graph) + graph.delegate = delegate + graph.append(feature: ReuseFeature()) + graph.removedState = .hiddenForReuse + #expect(graph[ReuseFeature.self]?.pointee.states == [true]) + #expect(delegate.changes == 0) + graph.removedState = [] + #expect(delegate.changes == 1) + graph.instantiate() + delegate.changes = 0 + graph.removedState = .hiddenForReuse + graph.removedState = [] + #expect(graph[ReuseFeature.self]?.pointee.states == [true, false, true, false]) + #expect(delegate.changes == 2) + graph.requestImmediateUpdate() + #expect(delegate.delays == [0]) + } + } + + @Test + func displayListReturnsTransformedContent() { + Update.perform { + let graph = makeGraph(outputs: .displayList) + graph.append(feature: DisplayListFeature()) + graph.instantiate() + let (list, _) = graph.displayList() + #expect(list.items.count == 1) + guard case .effect(.identity, _) = list.items.first?.value else { + Issue.record("Expected the root display list to contain the applied state transform") + return + } + #expect(list.features.isEmpty) + } + } + + private func makeGraph(outputs: ViewGraph.Outputs = []) -> ViewGraph { + let graph = ViewGraph(rootViewType: EmptyView.self, requestedOutputs: outputs) + graph.setRootView(EmptyView()) + return graph + } + + private func makeBridge(in graph: ViewGraph) throws -> (PreferenceBridge, Attribute, Attribute) { + let (bridge, outputs) = graph.globalSubgraph.apply { + graph.data.hostPreferenceKeys.add(HostValueKey.self) + let bridge = PreferenceBridge() + var inputs = _ViewInputs( + graph.graphInputs, + position: graph.$zeroPoint, + size: graph.$proposedSize, + transform: graph.$transform, + containerPosition: graph.$zeroPoint, + hostPreferenceKeys: graph.data.$hostPreferenceKeys + ) + inputs.preferences.add(ValueKey.self) + inputs.preferences.add(HostPreferencesKey.self) + var outputs = PreferencesOutputs() + bridge.wrapOutputs(&outputs, inputs: inputs) + return (bridge, outputs) + } + return (bridge, try #require(outputs[ValueKey.self]), try #require(outputs.hostPreferenceValues)) + } + } + struct NextUpdateTests { typealias Update = ViewGraph.NextUpdate + @Test + func initialStateHasNoPendingUpdate() { + let update = Update() + #expect(update.time == .infinity) + #expect(update.interval == 0) + #expect(update.reasons.isEmpty) + } + @Test(arguments: [ + ([Double.nan], Double.infinity), + ([3, 1, 2, .nan], 1), + ([0.0, -0.0], 0.0), + ([-0.0, 0.0], -0.0), + ([3, -.infinity, 2], -.infinity), + ] as [([Double], Double)]) + func atKeepsEarliestTime(requests: [Double], expected: Double) { + var update = Update() + update.interval(1 / 120, reason: 7) + for request in requests { + update.at(Time(seconds: request)) + } + #expect(update.time.seconds.bitPattern == expected.bitPattern) + #expect(update.interval == 1 / 120.0) + #expect(update.reasons == [7]) + } + + @Test(arguments: [ + (-.infinity, Double.zero, Set()), + (.nan, Double.zero, Set()), (0.0, Double.zero, Set()), (159.0, Double.zero, Set()), + (Double(160).nextDown, Double.zero, Set()), (160.0, 1 / 80.0, [0x27_0000]), (319.0, 1 / 80.0, [0x27_0000]), + (Double(320).nextDown, 1 / 80.0, [0x27_0000]), (320.0, 1 / 120.0, [0x27_0000]), (1000.0, 1 / 120.0, [0x27_0000]), + (.infinity, 1 / 120.0, [0x27_0000]), ] as [(Double, Double, Set)]) func maxVelocity(velocity: Double, expectedInterval: Double, expectedReasons: Set) { var update = Update() @@ -30,6 +349,12 @@ struct ViewGraphTests { (0.025, nil, 0.025, Set()), (0.025, 1 as UInt32?, 0.025, [1] as Set), (0.0, nil, Double.zero, Set()), + (-0.0, 0, Double.zero, [0]), + (.infinity, nil, Double.zero, Set()), + (-.infinity, nil, Double.zero, Set()), + (.nan, UInt32.max, Double.zero, [UInt32.max]), + (-0.025, nil, -0.025, Set()), + (.leastNonzeroMagnitude, nil, .leastNonzeroMagnitude, Set()), ] as [(Double, UInt32?, Double, Set)]) func intervalWithReason(interval: Double, reason: UInt32?, expectedInterval: Double, expectedReasons: Set) { var update = Update() @@ -37,5 +362,132 @@ struct ViewGraphTests { #expect(update.interval == expectedInterval) #expect(update.reasons == expectedReasons) } + + @Test(arguments: [0.025, 1 / 120.0]) + func nanIntervalPreservesExistingRequest(interval: Double) { + var update = Update() + update.interval(interval, reason: 1) + update.interval(.nan, reason: 2) + #expect(update.interval == interval) + #expect(update.reasons == [1, 2]) + } + + @Test(arguments: [ + (1 / 30.0, Double.zero), + ((1 / 60.0).nextUp, Double.zero), + (1 / 60.0, 1 / 60.0), + ((1 / 60.0).nextDown, (1 / 60.0).nextDown), + (1 / 120.0, 1 / 120.0), + ] as [(Double, Double)]) + func defaultIntervalSuppressesOnlySlowerRates(interval: Double, expected: Double) { + for defaultFirst in [false, true] { + var update = Update() + let requests = defaultFirst ? [0, interval] : [interval, 0] + for request in requests { + update.interval(request, reason: 1) + } + #expect(update.interval == expected) + #expect(update.reasons == [1]) + } + } + + @Test + func requestsKeepFastestRateAndAccumulateReasons() { + var update = Update() + update.at(Time(seconds: 3)) + update.interval(0, reason: 0) + update.maxVelocity(160) + #expect(update.interval == 1 / 80.0) + update.maxVelocity(320) + #expect(update.interval == 1 / 120.0) + update.interval(1 / 240, reason: 7) + update.maxVelocity(160) + update.interval(1 / 30, reason: 7) + update.interval(.nan, reason: UInt32.max) + #expect(update.interval == 1 / 240.0) + #expect(update.time == Time(seconds: 3)) + #expect(update.reasons == [0, 7, UInt32.max, 0x27_0000]) + } + } + + private struct AlignmentEngine: LayoutEngine { + func sizeThatFits(_ proposedSize: _ProposedSize) -> CGSize { + proposedSize.fixingUnspecifiedDimensions() + } + + func explicitAlignment(_ key: AlignmentKey, at size: ViewSize) -> CGFloat? { + #expect(size == .fixed(CGSize(width: 64, height: 46))) + return key == HorizontalAlignment.center.key ? 7 : 9 + } + } + + private struct ValueKey: PreferenceKey { + static let defaultValue = 0 + + static func reduce(value: inout Int, nextValue: () -> Int) { + value += nextValue() + } + } + + private struct HostValueKey: PreferenceKey { + static let defaultValue = 0 + + static func reduce(value: inout Int, nextValue: () -> Int) { + value += nextValue() + } + } + + private struct PreferenceFeature: ViewGraphFeature { + func modifyViewOutputs(outputs: inout _ViewOutputs, inputs: _ViewInputs, graph: ViewGraph) { + outputs[ValueKey.self] = Attribute(value: 42) + var values = PreferenceValues() + values[HostValueKey.self] = .init(value: 9, seed: .init(value: 1)) + outputs.preferences.hostPreferenceValues = Attribute(value: values) + } + } + + private struct ReuseFeature: ViewGraphFeature { + var states: [Bool] = [] + + mutating func isHiddenForReuseDidChange(graph: ViewGraph) { + states.append(graph.data.isHiddenForReuse) + } + } + + private struct DisplayListFeature: ViewGraphFeature { + func modifyViewOutputs(outputs: inout _ViewOutputs, inputs: _ViewInputs, graph: ViewGraph) { + outputs.preferences.displayList = Attribute(value: DisplayList(.init( + .effect(.state(StrongHash(of: 1)), DisplayList()), + frame: CGRect(x: 0, y: 0, width: 10, height: 10), + identity: .init(decodedValue: 1), + version: .init(decodedValue: 1) + ))) + } + } + + private struct BridgeUpdate: Rule { + let graph: ViewGraph + let environment: EnvironmentValues + let deferredUpdate: () -> Void + + var value: Bool { + graph.updatePreferenceBridge(environment: environment, deferredUpdate: deferredUpdate) + return GraphHost.isUpdating + } + } + + private final class Delegate: ViewGraphDelegate { + unowned let graph: ViewGraph + var changes = 0 + var delays: [Double] = [] + + init(graph: ViewGraph) { + self.graph = graph + } + + func updateViewGraph(body: (ViewGraph) -> T) -> T { body(graph) } + func graphDidChange() { changes += 1 } + func preferencesDidChange() {} + func requestUpdate(after delay: Double) { delays.append(delay) } } }