diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58d1fd2bd..a84fbdb1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v2 - uses: swiftwasm/swiftwasm-action@v5.6 with: - shell-action: carton test + shell-action: carton test --environment node # Disabled until macos-12 is available on GitHub Actions, which is required for Xcode 13.3 # core_macos_build: diff --git a/Package.swift b/Package.swift index ec4248b5f..d04a6f804 100644 --- a/Package.swift +++ b/Package.swift @@ -135,6 +135,7 @@ let package = Package( dependencies: [ .product(name: "Benchmark", package: "swift-benchmark"), "TokamakCore", + "TokamakTestRenderer", ] ), .executableTarget( @@ -194,6 +195,13 @@ let package = Package( name: "TokamakTestRenderer", dependencies: ["TokamakCore"] ), + .testTarget( + name: "TokamakReconcilerTests", + dependencies: [ + "TokamakCore", + "TokamakTestRenderer", + ] + ), .testTarget( name: "TokamakTests", dependencies: ["TokamakTestRenderer"] diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift new file mode 100644 index 000000000..b592d61b9 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -0,0 +1,268 @@ +// Copyright 2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 2/15/22. +// + +@_spi(TokamakCore) public extension FiberReconciler { + /// A manager for a single `View`. + /// + /// There are always 2 `Fiber`s for every `View` in the tree, + /// a current `Fiber`, and a work in progress `Fiber`. + /// They point to each other using the `alternate` property. + /// + /// The current `Fiber` represents the `View` as it is currently rendered on the screen. + /// The work in progress `Fiber` (the `alternate` of current), + /// is used in the reconciler to compute the new tree. + /// + /// When reconciling, the tree is recomputed from + /// the root of the state change on the work in progress `Fiber`. + /// Each node in the fiber tree is updated to apply any changes, + /// and a list of mutations needed to get the rendered output to match is created. + /// + /// After the entire tree has been traversed, the current and work in progress trees are swapped, + /// making the updated tree the current one, + /// and leaving the previous current tree available to apply future changes on. + final class Fiber: CustomDebugStringConvertible { + weak var reconciler: FiberReconciler? + + /// The underlying `View` instance. + /// + /// Stored as an IUO because we must use the `bindProperties` method + /// to create the `View` with its dependencies setup, + /// which requires all stored properties be set before using. + @_spi(TokamakCore) public var view: Any! + /// Outputs from evaluating `View._makeView` + /// + /// Stored as an IUO because creating `ViewOutputs` depends on + /// the `bindProperties` method, which requires + /// all stored properties be set before using. + /// `outputs` is guaranteed to be set in the initializer. + var outputs: ViewOutputs! + /// A function to visit `view` generically. + /// + /// Stored as an IUO because it captures a weak reference to `self`, which requires all stored properties be set before capturing. + var visitView: ((ViewVisitor) -> ())! + /// The identity of this `View` + var id: Identity? + /// The mounted element, if this is a Renderer primitive. + var element: Renderer.ElementType? + /// The first child node. + @_spi(TokamakCore) public var child: Fiber? + /// This node's right sibling. + @_spi(TokamakCore) public var sibling: Fiber? + /// An unowned reference to the parent node. + /// + /// Parent references are `unowned` (as opposed to `weak`) + /// because the parent will always exist if a child does. + /// If the parent is released, the child is released with it. + unowned var parent: Fiber? + /// The nearest parent that can be mounted on. + unowned var elementParent: Fiber? + /// The cached type information for the underlying `View`. + var typeInfo: TypeInfo? + /// Boxes that store `State` data. + var state: [PropertyInfo: MutableStorage] = [:] + + /// The WIP node if this is current, or the current node if this is WIP. + weak var alternate: Fiber? + + var createAndBindAlternate: (() -> Fiber)? + + /// A box holding a value for an `@State` property wrapper. + /// Will call `onSet` (usually a `Reconciler.reconcile` call) when updated. + final class MutableStorage { + private(set) var value: Any + let onSet: () -> () + + func setValue(_ newValue: Any, with transaction: Transaction) { + value = newValue + onSet() + } + + init(initialValue: Any, onSet: @escaping () -> ()) { + value = initialValue + self.onSet = onSet + } + } + + public enum Identity: Hashable { + case explicit(AnyHashable) + case structural(index: Int) + } + + init( + _ view: inout V, + element: Renderer.ElementType?, + parent: Fiber?, + elementParent: Fiber?, + childIndex: Int, + reconciler: FiberReconciler? + ) { + self.reconciler = reconciler + child = nil + sibling = nil + self.parent = parent + self.elementParent = elementParent + typeInfo = TokamakCore.typeInfo(of: V.self) + + let viewInputs = ViewInputs( + view: view, + proposedSize: parent?.outputs.layoutComputer?.proposeSize(for: view, at: childIndex), + environment: parent?.outputs.environment ?? .init(.init()) + ) + state = bindProperties(to: &view, typeInfo, viewInputs) + self.view = view + outputs = V._makeView(viewInputs) + visitView = { [weak self] in + guard let self = self else { return } + // swiftlint:disable:next force_cast + $0.visit(self.view as! V) + } + + if let element = element { + self.element = element + } else if Renderer.isPrimitive(view) { + self.element = .init(from: .init(from: view)) + } + + let alternateView = view + createAndBindAlternate = { + // Create the alternate lazily + let alternate = Fiber( + bound: alternateView, + alternate: self, + outputs: self.outputs, + typeInfo: self.typeInfo, + element: self.element, + parent: self.parent?.alternate, + elementParent: self.elementParent?.alternate, + reconciler: reconciler + ) + self.alternate = alternate + if self.parent?.child === self { + self.parent?.alternate?.child = alternate // Link it with our parent's alternate. + } else { + // Find our left sibling. + var node = self.parent?.child + while node?.sibling !== self { + guard node?.sibling != nil else { return alternate } + node = node?.sibling + } + if node?.sibling === self { + node?.alternate?.sibling = alternate // Link it with our left sibling's alternate. + } + } + return alternate + } + } + + init( + bound view: V, + alternate: Fiber, + outputs: ViewOutputs, + typeInfo: TypeInfo?, + element: Renderer.ElementType?, + parent: FiberReconciler.Fiber?, + elementParent: Fiber?, + reconciler: FiberReconciler? + ) { + self.view = view + self.alternate = alternate + self.reconciler = reconciler + self.element = element + child = nil + sibling = nil + self.parent = parent + self.elementParent = elementParent + self.typeInfo = typeInfo + self.outputs = outputs + visitView = { [weak self] in + guard let self = self else { return } + // swiftlint:disable:next force_cast + $0.visit(self.view as! V) + } + } + + private func bindProperties( + to view: inout V, + _ typeInfo: TypeInfo?, + _ viewInputs: ViewInputs + ) -> [PropertyInfo: MutableStorage] { + guard let typeInfo = typeInfo else { return [:] } + + var state: [PropertyInfo: MutableStorage] = [:] + for property in typeInfo.properties where property.type is DynamicProperty.Type { + var value = property.get(from: view) + if var storage = value as? WritableValueStorage { + let box = MutableStorage(initialValue: storage.anyInitialValue, onSet: { [weak self] in + guard let self = self else { return } + self.reconciler?.reconcile(from: self) + }) + state[property] = box + storage.getter = { box.value } + storage.setter = { box.setValue($0, with: $1) } + value = storage + } else if var environmentReader = value as? EnvironmentReader { + environmentReader.setContent(from: viewInputs.environment.environment) + value = environmentReader + } + property.set(value: value, on: &view) + } + return state + } + + func update( + with view: inout V, + childIndex: Int + ) -> Renderer.ElementType.Content? { + typeInfo = TokamakCore.typeInfo(of: V.self) + + let viewInputs = ViewInputs( + view: view, + proposedSize: parent?.outputs.layoutComputer?.proposeSize(for: view, at: childIndex), + environment: parent?.outputs.environment ?? .init(.init()) + ) + state = bindProperties(to: &view, typeInfo, viewInputs) + self.view = view + outputs = V._makeView(viewInputs) + visitView = { [weak self] in + guard let self = self else { return } + // swiftlint:disable:next force_cast + $0.visit(self.view as! V) + } + + if Renderer.isPrimitive(view) { + return .init(from: view) + } else { + return nil + } + } + + public var debugDescription: String { + flush() + } + + private func flush(level: Int = 0) -> String { + let spaces = String(repeating: " ", count: level) + return """ + \(spaces)\(String(describing: typeInfo?.type ?? Any.self) + .split(separator: "<")[0])\(element != nil ? "(\(element!))" : "") { + \(child?.flush(level: level + 2) ?? "") + \(spaces)} + \(sibling?.flush(level: level) ?? "") + """ + } + } +} diff --git a/Sources/TokamakCore/Fiber/FiberElement.swift b/Sources/TokamakCore/Fiber/FiberElement.swift new file mode 100644 index 000000000..27e5ee4c5 --- /dev/null +++ b/Sources/TokamakCore/Fiber/FiberElement.swift @@ -0,0 +1,33 @@ +// Copyright 2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 2/15/22. +// + +/// A reference type that points to a `Renderer`-specific element that has been mounted. +/// For instance, a DOM node in the `DOMFiberRenderer`. +public protocol FiberElement: AnyObject { + associatedtype Content: FiberElementContent + var content: Content { get } + init(from content: Content) + func update(with content: Content) +} + +/// The data used to create an `FiberElement`. +/// +/// We re-use `FiberElement` instances in the `Fiber` tree, +/// but can re-create and copy `FiberElementContent` as often as needed. +public protocol FiberElementContent: Equatable { + init(from primitiveView: V) +} diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift new file mode 100644 index 000000000..0e214dbe3 --- /dev/null +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -0,0 +1,320 @@ +// Copyright 2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 2/15/22. +// + +/// A reconciler modeled after React's [Fiber reconciler](https://reactjs.org/docs/faq-internals.html#what-is-react-fiber) +public final class FiberReconciler { + /// The root node in the `Fiber` tree that represents the `View`s currently rendered on screen. + @_spi(TokamakCore) public var current: Fiber! + /// The alternate of `current`, or the work in progress tree root. + /// + /// We must keep a strong reference to both the current and alternate tree roots, + /// as they only keep weak references to each other. + private var alternate: Fiber! + /// The `FiberRenderer` used to create and update the `Element`s on screen. + public let renderer: Renderer + + public init(_ renderer: Renderer, _ view: V) { + self.renderer = renderer + var view = view.environmentValues(renderer.defaultEnvironment) + current = .init( + &view, + element: renderer.rootElement, + parent: nil, + elementParent: nil, + childIndex: 0, + reconciler: self + ) + // Start by building the initial tree. + alternate = current.createAndBindAlternate?() + reconcile(from: current) + } + + /// Convert the first level of children of a `View` into a linked list of `Fiber`s. + struct TreeReducer: ViewReducer { + final class Result { + // For references + let fiber: Fiber? + let visitChildren: (TreeReducer.Visitor) -> () + unowned var parent: Result? + var child: Result? + var sibling: Result? + var newContent: Renderer.ElementType.Content? + + // For reducing + var childrenCount: Int = 0 + var lastSibling: Result? + var nextExisting: Fiber? + var nextExistingAlternate: Fiber? + + init( + fiber: Fiber?, + visitChildren: @escaping (TreeReducer.Visitor) -> (), + parent: Result?, + child: Fiber?, + alternateChild: Fiber?, + newContent: Renderer.ElementType.Content? = nil + ) { + self.fiber = fiber + self.visitChildren = visitChildren + self.parent = parent + nextExisting = child + nextExistingAlternate = alternateChild + self.newContent = newContent + } + } + + static func reduce(into partialResult: inout Result, nextView: V) where V: View { + // Create the node and its element. + var nextView = nextView + let resultChild: Result + if let existing = partialResult.nextExisting { + // If a fiber already exists, simply update it with the new view. + let newContent = existing.update( + with: &nextView, + childIndex: partialResult.childrenCount + ) + resultChild = Result( + fiber: existing, + visitChildren: nextView._visitChildren, + parent: partialResult, + child: existing.child, + alternateChild: existing.alternate?.child, + newContent: newContent + ) + partialResult.nextExisting = existing.sibling + } else { + // Otherwise, create a new fiber for this child. + let fiber = Fiber( + &nextView, + element: partialResult.nextExistingAlternate?.element, + parent: partialResult.fiber, + elementParent: partialResult.fiber?.element != nil + ? partialResult.fiber + : partialResult.fiber?.elementParent, + childIndex: partialResult.childrenCount, + reconciler: partialResult.fiber?.reconciler + ) + // If a fiber already exists for an alternate, link them. + if let alternate = partialResult.nextExistingAlternate { + fiber.alternate = alternate + partialResult.nextExistingAlternate = alternate.sibling + } + resultChild = Result( + fiber: fiber, + visitChildren: nextView._visitChildren, + parent: partialResult, + child: nil, + alternateChild: fiber.alternate?.child + ) + } + // Keep track of the index of the child so the LayoutComputer can propose sizes. + partialResult.childrenCount += 1 + // Get the last child element we've processed, and add the new child as its sibling. + if let lastSibling = partialResult.lastSibling { + lastSibling.fiber?.sibling = resultChild.fiber + lastSibling.sibling = resultChild + } else { + // Otherwise setup the first child + partialResult.fiber?.child = resultChild.fiber + partialResult.child = resultChild + } + partialResult.lastSibling = resultChild + } + } + + final class ReconcilerVisitor: ViewVisitor { + unowned let reconciler: FiberReconciler + /// The current, mounted `Fiber`. + var currentRoot: Fiber + var mutations = [Mutation]() + + init(root: Fiber, reconciler: FiberReconciler) { + self.reconciler = reconciler + currentRoot = root + } + + /// Walk the current tree, recomputing at each step to check for discrepancies. + /// + /// Parent-first depth-first traversal. + /// Take this `View` tree for example. + /// ```swift + /// VStack { + /// HStack { + /// Text("A") + /// Text("B") + /// } + /// Text("C") + /// } + /// ``` + /// Basically, we read it like this: + /// 1. `VStack` has children, so we go to it's first child, `HStack`. + /// 2. `HStack` has children, so we go further to it's first child, `Text`. + /// 3. `Text` has no child, but has a sibling, so we go to that. + /// 4. `Text` has no child and no sibling, so we return to the `HStack`. + /// 5. We've already read the children, so we look for a sibling, `Text`. + /// 6. `Text` has no children and no sibling, so we return to the `VStack.` + /// We finish once we've returned to the root element. + /// ``` + /// ┌──────┐ + /// │VStack│ + /// └──┬───┘ + /// ▲ 1 │ + /// │ └──►┌──────┐ + /// │ │HStack│ + /// │ ┌─┴───┬──┘ + /// │ │ ▲ │ 2 + /// │ │ │ │ ┌────┐ + /// │ │ │ └─►│Text├─┐ + /// 6 │ │ 4 │ └────┘ │ + /// │ │ │ │ 3 + /// │ 5 │ │ ┌────┐ │ + /// │ │ └────┤Text│◄┘ + /// │ │ └────┘ + /// │ │ + /// │ └►┌────┐ + /// │ │Text│ + /// └───────┴────┘ + /// ``` + func visit(_ view: V) where V: View { + let alternateRoot: Fiber? + if let alternate = currentRoot.alternate { + alternateRoot = alternate + } else { + alternateRoot = currentRoot.createAndBindAlternate?() + } + let rootResult = TreeReducer.Result( + fiber: alternateRoot, // The alternate is the WIP node. + visitChildren: view._visitChildren, + parent: nil, + child: alternateRoot?.child, + alternateChild: currentRoot.child + ) + var node = rootResult + + /// A dictionary keyed by the unique ID of an element, with a value indicating what index + /// we are currently at. This ensures we place children in the correct order, even if they are + /// at different levels in the `View` tree. + var elementIndices = [ObjectIdentifier: Int]() + + /// Compare `node` with its alternate, and add any mutations to the list. + func reconcile(_ node: TreeReducer.Result) { + if let element = node.fiber?.element, + let parent = node.fiber?.elementParent?.element + { + let key = ObjectIdentifier(parent) + let index = elementIndices[key, default: 0] + if node.fiber?.alternate == nil { // This didn't exist before (no alternate) + mutations.append(.insert(element: element, parent: parent, index: index)) + } else if node.fiber?.typeInfo?.type != node.fiber?.alternate?.typeInfo?.type, + let previous = node.fiber?.alternate?.element + { + // This is a completely different type of view. + mutations.append(.replace(parent: parent, previous: previous, replacement: element)) + } else if let newContent = node.newContent, + newContent != element.content + { + // This is the same type of view, but its backing data has changed. + mutations.append(.update(previous: element, newContent: newContent)) + } + elementIndices[key] = index + 1 + } + } + + // The main reconciler loop. + while true { + // Perform work on the node. + reconcile(node) + + // Compute the children of the node. + let reducer = TreeReducer.Visitor(initialResult: node) + node.visitChildren(reducer) + + // Setup the alternate if it doesn't exist yet. + if node.fiber?.alternate == nil { + _ = node.fiber?.createAndBindAlternate?() + } + + // Walk all down all the way into the deepest child. + if let child = reducer.result.child { + node = child + continue + } else if let alternateChild = node.fiber?.alternate?.child { + walk(alternateChild) { node in + if let element = node.element, + let parent = node.elementParent?.element + { + // The alternate has a child that no longer exists. + // Removals must happen in reverse order, so a child element + // is removed before its parent. + self.mutations.insert(.remove(element: element, parent: parent), at: 0) + } + return true + } + } + if reducer.result.child == nil { + node.fiber?.child = nil // Make sure we clear the child if there was none + } + + // If we've made it back to the root, then exit. + if node === rootResult { + return + } + // Now walk back up the tree until we find a sibling. + while node.sibling == nil { + var alternateSibling = node.fiber?.alternate?.sibling + while alternateSibling != nil { // The alternate had siblings that no longer exist. + if let element = alternateSibling?.element, + let parent = alternateSibling?.elementParent?.element + { + // Removals happen in reverse order, so a child element is removed before + // its parent. + mutations.insert(.remove(element: element, parent: parent), at: 0) + } + alternateSibling = alternateSibling?.sibling + } + // When we walk back to the root, exit + guard let parent = node.parent, + parent !== currentRoot.alternate + else { + return + } + node = parent + } + // Walk across to the sibling, and repeat. + // swiftlint:disable:next force_unwrap + node = node.sibling! + } + } + } + + func reconcile(from root: Fiber) { + // Create a list of mutations. + let visitor = ReconcilerVisitor(root: root, reconciler: self) + root.visitView(visitor) + + // Apply mutations to the rendered output. + renderer.commit(visitor.mutations) + + // Swap the root out for its alternate. + // Essentially, making the work in progress tree the current, + // and leaving the current available to be the work in progress + // on our next update. + let child = root.child + root.child = root.alternate?.child + root.alternate?.child = child + } +} diff --git a/Sources/TokamakCore/Fiber/FiberRenderer.swift b/Sources/TokamakCore/Fiber/FiberRenderer.swift new file mode 100644 index 000000000..5bdf4db88 --- /dev/null +++ b/Sources/TokamakCore/Fiber/FiberRenderer.swift @@ -0,0 +1,39 @@ +// Copyright 2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 2/15/22. +// + +/// A renderer capable of performing mutations specified by a `FiberReconciler`. +public protocol FiberRenderer { + /// The element class this renderer uses. + associatedtype ElementType: FiberElement + /// Check whether a `View` is a primitive for this renderer. + static func isPrimitive(_ view: V) -> Bool where V: View + /// Apply the mutations to the elements. + func commit(_ mutations: [Mutation]) + /// The root element all top level views should be mounted on. + var rootElement: ElementType { get } + /// The smallest set of initial `EnvironmentValues` needed for this renderer to function. + var defaultEnvironment: EnvironmentValues { get } +} + +public extension FiberRenderer { + var defaultEnvironment: EnvironmentValues { .init() } + + @discardableResult + func render(_ view: V) -> FiberReconciler { + .init(self, view) + } +} diff --git a/Sources/TokamakCore/Fiber/LayoutComputer.swift b/Sources/TokamakCore/Fiber/LayoutComputer.swift new file mode 100644 index 000000000..4d49d8366 --- /dev/null +++ b/Sources/TokamakCore/Fiber/LayoutComputer.swift @@ -0,0 +1,25 @@ +// Copyright 2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 2/16/22. +// + +import Foundation + +/// A type that is able to propose sizes for its children. +protocol LayoutComputer { + /// Will be called every time a child is evaluated. + /// The calls will always be in order, and no more than one call will be made per child. + func proposeSize(for child: V, at index: Int) -> CGSize +} diff --git a/Sources/TokamakCore/Fiber/Mutation.swift b/Sources/TokamakCore/Fiber/Mutation.swift new file mode 100644 index 000000000..f29d0cfaf --- /dev/null +++ b/Sources/TokamakCore/Fiber/Mutation.swift @@ -0,0 +1,31 @@ +// Copyright 2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 2/15/22. +// + +public enum Mutation { + case insert( + element: Renderer.ElementType, + parent: Renderer.ElementType, + index: Int + ) + case remove(element: Renderer.ElementType, parent: Renderer.ElementType?) + case replace( + parent: Renderer.ElementType, + previous: Renderer.ElementType, + replacement: Renderer.ElementType + ) + case update(previous: Renderer.ElementType, newContent: Renderer.ElementType.Content) +} diff --git a/Sources/TokamakCore/Fiber/ViewArguments.swift b/Sources/TokamakCore/Fiber/ViewArguments.swift new file mode 100644 index 000000000..ef35031fa --- /dev/null +++ b/Sources/TokamakCore/Fiber/ViewArguments.swift @@ -0,0 +1,86 @@ +// Copyright 2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 2/7/22. +// + +import Foundation + +/// Data passed to `_makeView` to create the `ViewOutputs` used in reconciling/rendering. +public struct ViewInputs { + let view: V + /// The size proposed by this view's parent. + let proposedSize: CGSize? + let environment: EnvironmentBox +} + +/// Data used to reconcile and render a `View` and its children. +public struct ViewOutputs { + /// A container for the current `EnvironmentValues`. + /// This is stored as a reference to avoid copying the environment when unnecessary. + let environment: EnvironmentBox + let preferences: _PreferenceStore + /// The size requested by this view. + let size: CGSize + /// The `LayoutComputer` used to propose sizes for the children of this view. + let layoutComputer: LayoutComputer? +} + +final class EnvironmentBox { + let environment: EnvironmentValues + + init(_ environment: EnvironmentValues) { + self.environment = environment + } +} + +extension ViewOutputs { + init( + inputs: ViewInputs, + environment: EnvironmentValues? = nil, + preferences: _PreferenceStore? = nil, + size: CGSize? = nil, + layoutComputer: LayoutComputer? = nil + ) { + // Only replace the EnvironmentBox when we change the environment. Otherwise the same box can be reused. + self.environment = environment.map(EnvironmentBox.init) ?? inputs.environment + self.preferences = preferences ?? .init() + self.size = size ?? inputs.proposedSize ?? .zero + self.layoutComputer = layoutComputer + } +} + +public extension View { + // By default, we simply pass the inputs through without modifications + // or layout considerations. + static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + .init(inputs: inputs) + } +} + +public extension ModifiedContent where Content: View, Modifier: ViewModifier { + static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + // Update the environment if needed. + var environment = inputs.environment.environment + if let environmentWriter = inputs.view.modifier as? EnvironmentModifier { + environmentWriter.modifyEnvironment(&environment) + } + return .init(inputs: inputs, environment: environment) + } + + func _visitChildren(_ visitor: V) where V: ViewVisitor { + // Visit the computed body of the modifier. + visitor.visit(modifier.body(content: .init(modifier: modifier, view: content))) + } +} diff --git a/Sources/TokamakCore/Fiber/ViewVisitor.swift b/Sources/TokamakCore/Fiber/ViewVisitor.swift new file mode 100644 index 000000000..5f972238f --- /dev/null +++ b/Sources/TokamakCore/Fiber/ViewVisitor.swift @@ -0,0 +1,62 @@ +// Copyright 2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 2/3/22. +// + +public protocol ViewVisitor { + func visit(_ view: V) +} + +public extension View { + func _visitChildren(_ visitor: V) { + visitor.visit(body) + } +} + +typealias ViewVisitorF = (V) -> () + +protocol ViewReducer { + associatedtype Result + static func reduce(into partialResult: inout Result, nextView: V) + static func reduce(partialResult: Result, nextView: V) -> Result +} + +extension ViewReducer { + static func reduce(into partialResult: inout Result, nextView: V) { + partialResult = Self.reduce(partialResult: partialResult, nextView: nextView) + } + + static func reduce(partialResult: Result, nextView: V) -> Result { + var result = partialResult + Self.reduce(into: &result, nextView: nextView) + return result + } +} + +final class ReducerVisitor: ViewVisitor { + var result: R.Result + + init(initialResult: R.Result) { + result = initialResult + } + + func visit(_ view: V) where V: View { + R.reduce(into: &result, nextView: view) + } +} + +extension ViewReducer { + typealias Visitor = ReducerVisitor +} diff --git a/Sources/TokamakCore/Fiber/walk.swift b/Sources/TokamakCore/Fiber/walk.swift new file mode 100644 index 000000000..7e00c8ab4 --- /dev/null +++ b/Sources/TokamakCore/Fiber/walk.swift @@ -0,0 +1,73 @@ +// Copyright 2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 2/11/22. +// + +enum WalkWorkResult { + case `continue` + case `break`(with: Success) + case pause +} + +enum WalkResult { + case success(Success) + case finished + case paused(at: FiberReconciler.Fiber) +} + +@discardableResult +func walk( + _ root: FiberReconciler.Fiber, + _ work: @escaping (FiberReconciler.Fiber) throws -> Bool +) rethrows -> WalkResult { + try walk(root) { + try work($0) ? .continue : .pause + } +} + +/// Parent-first depth-first traversal of a `Fiber` tree. +func walk( + _ root: FiberReconciler.Fiber, + _ work: @escaping (FiberReconciler.Fiber) throws -> WalkWorkResult +) rethrows -> WalkResult { + var current = root + while true { + // Perform work on the node + switch try work(current) { + case .continue: break + case let .break(success): return .success(success) + case .pause: return .paused(at: current) + } + // Walk into the child + if let child = current.child { + current = child + continue + } + // When we walk back to the root, exit + if current === root { + return .finished + } + // Walk back up until we find a sibling + while current.sibling == nil { + // When we walk back to the root, exit + guard let parent = current.parent, + parent !== root else { return .finished } + current = parent + } + // Walk the sibling + // swiftlint:disable:next force_unwrap + current = current.sibling! + } +} diff --git a/Sources/TokamakCore/Modifiers/ViewModifier.swift b/Sources/TokamakCore/Modifiers/ViewModifier.swift index d4d343fe6..436e672f8 100644 --- a/Sources/TokamakCore/Modifiers/ViewModifier.swift +++ b/Sources/TokamakCore/Modifiers/ViewModifier.swift @@ -23,15 +23,27 @@ public struct _ViewModifier_Content: View { public let modifier: Modifier public let view: AnyView + let visitChildren: (ViewVisitor) -> () public init(modifier: Modifier, view: AnyView) { self.modifier = modifier self.view = view + visitChildren = { $0.visit(view) } + } + + public init(modifier: Modifier, view: V) { + self.modifier = modifier + self.view = AnyView(view) + visitChildren = { $0.visit(view) } } public var body: some View { view } + + public func _visitChildren(_ visitor: V) where V: ViewVisitor { + visitChildren(visitor) + } } public extension View { diff --git a/Sources/TokamakCore/Reflection/Models/PropertyInfo.swift b/Sources/TokamakCore/Reflection/Models/PropertyInfo.swift index 3f09a164e..7357cdea8 100644 --- a/Sources/TokamakCore/Reflection/Models/PropertyInfo.swift +++ b/Sources/TokamakCore/Reflection/Models/PropertyInfo.swift @@ -20,7 +20,21 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -public struct PropertyInfo { +public struct PropertyInfo: Hashable { + // Hashable/Equatable conformance is not synthesize for metatypes. + public static func == (lhs: PropertyInfo, rhs: PropertyInfo) -> Bool { + lhs.name == rhs.name && lhs.type == rhs.type && lhs.isVar == rhs.isVar && lhs.offset == rhs + .offset && lhs.ownerType == rhs.ownerType + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(ObjectIdentifier(type)) + hasher.combine(isVar) + hasher.combine(offset) + hasher.combine(ObjectIdentifier(ownerType)) + } + public let name: String public let type: Any.Type public let isVar: Bool diff --git a/Sources/TokamakCore/Views/AnyView.swift b/Sources/TokamakCore/Views/AnyView.swift index 47e0263a5..fd9170380 100644 --- a/Sources/TokamakCore/Views/AnyView.swift +++ b/Sources/TokamakCore/Views/AnyView.swift @@ -40,6 +40,8 @@ public struct AnyView: _PrimitiveView { */ let bodyType: Any.Type + let visitChildren: (ViewVisitor, Any) -> () + public init(_ view: V) where V: View { if let anyView = view as? AnyView { self = anyView @@ -52,8 +54,14 @@ public struct AnyView: _PrimitiveView { self.view = view // swiftlint:disable:next force_cast bodyClosure = { AnyView(($0 as! V).body) } + // swiftlint:disable:next force_cast + visitChildren = { $0.visit($1 as! V) } } } + + public func _visitChildren(_ visitor: V) where V: ViewVisitor { + visitChildren(visitor, view) + } } public func mapAnyView(_ anyView: AnyView, transform: (V) -> T) -> T? { diff --git a/Sources/TokamakCore/Views/Containers/ForEach.swift b/Sources/TokamakCore/Views/Containers/ForEach.swift index 88a3eff8a..ef2bf6ef9 100644 --- a/Sources/TokamakCore/Views/Containers/ForEach.swift +++ b/Sources/TokamakCore/Views/Containers/ForEach.swift @@ -48,6 +48,12 @@ public struct ForEach: _PrimitiveView where Data: RandomAcces self.id = id self.content = content } + + public func _visitChildren(_ visitor: V) where V: ViewVisitor { + for element in data { + visitor.visit(content(element)) + } + } } extension ForEach: ForEachProtocol where Data.Index == Int { diff --git a/Sources/TokamakCore/Views/Containers/TupleView.swift b/Sources/TokamakCore/Views/Containers/TupleView.swift index 0f0cfdec0..6945589a5 100644 --- a/Sources/TokamakCore/Views/Containers/TupleView.swift +++ b/Sources/TokamakCore/Views/Containers/TupleView.swift @@ -22,26 +22,46 @@ public struct TupleView: _PrimitiveView { public let value: T let _children: [AnyView] + private let visit: (ViewVisitor) -> () public init(_ value: T) { self.value = value _children = [] + visit = { _ in } } public init(_ value: T, children: [AnyView]) { self.value = value _children = children + visit = { + for child in children { + $0.visit(child) + } + } + } + + public func _visitChildren(_ visitor: V) where V: ViewVisitor { + visit(visitor) } init(_ v1: T1, _ v2: T2) where T == (T1, T2) { value = (v1, v2) _children = [AnyView(v1), AnyView(v2)] + visit = { + $0.visit(v1) + $0.visit(v2) + } } // swiftlint:disable large_tuple init(_ v1: T1, _ v2: T2, _ v3: T3) where T == (T1, T2, T3) { value = (v1, v2, v3) _children = [AnyView(v1), AnyView(v2), AnyView(v3)] + visit = { + $0.visit(v1) + $0.visit(v2) + $0.visit(v3) + } } init(_ v1: T1, _ v2: T2, _ v3: T3, _ v4: T4) @@ -49,6 +69,12 @@ public struct TupleView: _PrimitiveView { { value = (v1, v2, v3, v4) _children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4)] + visit = { + $0.visit(v1) + $0.visit(v2) + $0.visit(v3) + $0.visit(v4) + } } init( @@ -60,6 +86,13 @@ public struct TupleView: _PrimitiveView { ) where T == (T1, T2, T3, T4, T5) { value = (v1, v2, v3, v4, v5) _children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4), AnyView(v5)] + visit = { + $0.visit(v1) + $0.visit(v2) + $0.visit(v3) + $0.visit(v4) + $0.visit(v5) + } } init( @@ -72,6 +105,14 @@ public struct TupleView: _PrimitiveView { ) where T == (T1, T2, T3, T4, T5, T6) { value = (v1, v2, v3, v4, v5, v6) _children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4), AnyView(v5), AnyView(v6)] + visit = { + $0.visit(v1) + $0.visit(v2) + $0.visit(v3) + $0.visit(v4) + $0.visit(v5) + $0.visit(v6) + } } init( @@ -93,6 +134,15 @@ public struct TupleView: _PrimitiveView { AnyView(v6), AnyView(v7), ] + visit = { + $0.visit(v1) + $0.visit(v2) + $0.visit(v3) + $0.visit(v4) + $0.visit(v5) + $0.visit(v6) + $0.visit(v7) + } } init( @@ -116,6 +166,16 @@ public struct TupleView: _PrimitiveView { AnyView(v7), AnyView(v8), ] + visit = { + $0.visit(v1) + $0.visit(v2) + $0.visit(v3) + $0.visit(v4) + $0.visit(v5) + $0.visit(v6) + $0.visit(v7) + $0.visit(v8) + } } init( @@ -141,6 +201,17 @@ public struct TupleView: _PrimitiveView { AnyView(v8), AnyView(v9), ] + visit = { + $0.visit(v1) + $0.visit(v2) + $0.visit(v3) + $0.visit(v4) + $0.visit(v5) + $0.visit(v6) + $0.visit(v7) + $0.visit(v8) + $0.visit(v9) + } } init< @@ -179,6 +250,18 @@ public struct TupleView: _PrimitiveView { AnyView(v9), AnyView(v10), ] + visit = { + $0.visit(v1) + $0.visit(v2) + $0.visit(v3) + $0.visit(v4) + $0.visit(v5) + $0.visit(v6) + $0.visit(v7) + $0.visit(v8) + $0.visit(v9) + $0.visit(v10) + } } } diff --git a/Sources/TokamakCore/Views/Controls/Button.swift b/Sources/TokamakCore/Views/Controls/Button.swift index a675bb86f..45c04becd 100644 --- a/Sources/TokamakCore/Views/Controls/Button.swift +++ b/Sources/TokamakCore/Views/Controls/Button.swift @@ -65,7 +65,7 @@ public struct Button