diff --git a/Package.swift b/Package.swift index 93a97067d..c062c573c 100644 --- a/Package.swift +++ b/Package.swift @@ -195,6 +195,18 @@ let package = Package( name: "TokamakTestRenderer", dependencies: ["TokamakCore"] ), + .testTarget( + name: "TokamakLayoutTests", + dependencies: [ + "TokamakCore", + "TokamakStaticHTML", + .product( + name: "SnapshotTesting", + package: "swift-snapshot-testing", + condition: .when(platforms: [.macOS]) + ), + ] + ), .testTarget( name: "TokamakReconcilerTests", dependencies: [ diff --git a/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift b/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift index dec9df180..82b69a634 100644 --- a/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift +++ b/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift @@ -28,7 +28,9 @@ extension FiberReconciler.Fiber: CustomDebugStringConvertible { private func flush(level: Int = 0) -> String { let spaces = String(repeating: " ", count: level) let geometry = geometry ?? .init( - origin: .init(origin: .zero), dimensions: .init(size: .zero, alignmentGuides: [:]) + origin: .init(origin: .zero), + dimensions: .init(size: .zero, alignmentGuides: [:]), + proposal: .unspecified ) return """ \(spaces)\(String(describing: typeInfo?.type ?? Any.self) diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index d19a0e37d..c9c0e50e7 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -47,6 +47,7 @@ public extension FiberReconciler { /// which requires all stored properties be set before capturing. @_spi(TokamakCore) public var content: Content! + /// Outputs from evaluating `View._makeView` /// /// Stored as an IUO because creating `ViewOutputs` depends on @@ -54,28 +55,42 @@ public extension FiberReconciler { /// all stored properties be set before using. /// `outputs` is guaranteed to be set in the initializer. var outputs: ViewOutputs! + + /// The erased `Layout` to use for this content. + /// + /// Stored as an IUO because it uses `bindProperties` to create the underlying instance. + var layout: AnyLayout? + /// The identity of this `View` var id: Identity? - /// The mounted element, if this is a Renderer primitive. + + /// The mounted element, if this is a `Renderer` primitive. var element: Renderer.ElementType? - /// The index of this element in its elementParent + + /// The index of this element in its `elementParent` var elementIndex: Int? + /// 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] = [:] @@ -115,6 +130,7 @@ public extension FiberReconciler { parent: Fiber?, elementParent: Fiber?, elementIndex: Int?, + traits: _ViewTraitStore?, reconciler: FiberReconciler? ) { self.reconciler = reconciler @@ -126,12 +142,12 @@ public extension FiberReconciler { let environment = parent?.outputs.environment ?? .init(.init()) state = bindProperties(to: &view, typeInfo, environment.environment) - outputs = V._makeView( - .init( - content: view, - environment: environment - ) + let viewInputs = ViewInputs( + content: view, + environment: environment, + traits: traits ) + outputs = V._makeView(viewInputs) content = content(for: view) @@ -143,6 +159,10 @@ public extension FiberReconciler { ) } + if self.element != nil { + layout = (view as? _AnyLayout)?._erased() ?? DefaultLayout.shared + } + // Only specify an `elementIndex` if we have an element. if self.element != nil { self.elementIndex = elementIndex @@ -154,6 +174,8 @@ public extension FiberReconciler { // Create the alternate lazily let alternate = Fiber( bound: alternateView, + state: self.state, + layout: self.layout, alternate: self, outputs: self.outputs, typeInfo: self.typeInfo, @@ -182,6 +204,8 @@ public extension FiberReconciler { init( bound view: V, + state: [PropertyInfo: MutableStorage], + layout: AnyLayout!, alternate: Fiber, outputs: ViewOutputs, typeInfo: TypeInfo?, @@ -199,6 +223,10 @@ public extension FiberReconciler { self.elementParent = elementParent self.typeInfo = typeInfo self.outputs = outputs + self.state = state + if element != nil { + self.layout = layout + } content = content(for: view) } @@ -213,10 +241,13 @@ public extension FiberReconciler { for property in typeInfo.properties where property.type is DynamicProperty.Type { var value = property.get(from: content) 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) - }) + let box = self.state[property] ?? 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) } @@ -237,7 +268,8 @@ public extension FiberReconciler { func update( with view: inout V, - elementIndex: Int? + elementIndex: Int?, + traits: _ViewTraitStore? ) -> Renderer.ElementType.Content? { typeInfo = TokamakCore.typeInfo(of: V.self) @@ -246,10 +278,16 @@ public extension FiberReconciler { let environment = parent?.outputs.environment ?? .init(.init()) state = bindProperties(to: &view, typeInfo, environment.environment) content = content(for: view) - outputs = V._makeView(.init( + let inputs = ViewInputs( content: view, - environment: environment - )) + environment: environment, + traits: traits + ) + outputs = V._makeView(inputs) + + if element != nil { + layout = (view as? _AnyLayout)?._erased() ?? DefaultLayout.shared + } if Renderer.isPrimitive(view) { return .init(from: view, useDynamicLayout: reconciler?.renderer.useDynamicLayout ?? false) @@ -272,21 +310,23 @@ public extension FiberReconciler { elementParent = nil element = rootElement typeInfo = TokamakCore.typeInfo(of: A.self) - state = bindProperties(to: &app, typeInfo, rootEnvironment) outputs = .init( - inputs: .init(content: app, environment: .init(rootEnvironment)), - layoutComputer: RootLayoutComputer.init + inputs: .init(content: app, environment: .init(rootEnvironment), traits: .init()) ) content = content(for: app) + layout = .init(RootLayout(renderer: reconciler.renderer)) + let alternateApp = app createAndBindAlternate = { [weak self] in guard let self = self else { return nil } // Create the alternate lazily let alternate = Fiber( bound: alternateApp, + state: self.state, + layout: self.layout, alternate: self, outputs: self.outputs, typeInfo: self.typeInfo, @@ -300,6 +340,8 @@ public extension FiberReconciler { init( bound app: A, + state: [PropertyInfo: MutableStorage], + layout: AnyLayout?, alternate: Fiber, outputs: SceneOutputs, typeInfo: TypeInfo?, @@ -315,6 +357,8 @@ public extension FiberReconciler { elementParent = nil self.typeInfo = typeInfo self.outputs = outputs + self.state = state + self.layout = layout content = content(for: app) } @@ -339,18 +383,25 @@ public extension FiberReconciler { outputs = S._makeScene( .init( content: scene, - environment: environment + environment: environment, + traits: .init() ) ) content = content(for: scene) + if element != nil { + layout = (scene as? _AnyLayout)?._erased() ?? DefaultLayout.shared + } + let alternateScene = scene createAndBindAlternate = { [weak self] in guard let self = self else { return nil } // Create the alternate lazily let alternate = Fiber( bound: alternateScene, + state: self.state, + layout: self.layout, alternate: self, outputs: self.outputs, typeInfo: self.typeInfo, @@ -379,6 +430,8 @@ public extension FiberReconciler { init( bound scene: S, + state: [PropertyInfo: MutableStorage], + layout: AnyLayout!, alternate: Fiber, outputs: SceneOutputs, typeInfo: TypeInfo?, @@ -396,6 +449,10 @@ public extension FiberReconciler { self.elementParent = elementParent self.typeInfo = typeInfo self.outputs = outputs + self.state = state + if element != nil { + self.layout = layout + } content = content(for: scene) } @@ -409,9 +466,14 @@ public extension FiberReconciler { content = content(for: scene) outputs = S._makeScene(.init( content: scene, - environment: environment + environment: environment, + traits: .init() )) + if element != nil { + layout = (scene as? _AnyLayout)?._erased() ?? DefaultLayout.shared + } + return nil } } diff --git a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift index 8ff5e42df..46db9e2de 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift @@ -29,12 +29,12 @@ extension FiberReconciler { var sibling: Result? var newContent: Renderer.ElementType.Content? var elementIndices: [ObjectIdentifier: Int] - var layoutContexts: [ObjectIdentifier: LayoutContext] // For reducing var lastSibling: Result? var nextExisting: Fiber? var nextExistingAlternate: Fiber? + var pendingTraits: _ViewTraitStore init( fiber: Fiber?, @@ -44,7 +44,7 @@ extension FiberReconciler { alternateChild: Fiber?, newContent: Renderer.ElementType.Content? = nil, elementIndices: [ObjectIdentifier: Int], - layoutContexts: [ObjectIdentifier: LayoutContext] + pendingTraits: _ViewTraitStore ) { self.fiber = fiber self.visitChildren = visitChildren @@ -53,7 +53,7 @@ extension FiberReconciler { nextExistingAlternate = alternateChild self.newContent = newContent self.elementIndices = elementIndices - self.layoutContexts = layoutContexts + self.pendingTraits = pendingTraits } } @@ -61,7 +61,7 @@ extension FiberReconciler { Self.reduce( into: &partialResult, nextValue: nextScene, - createFiber: { scene, element, parent, elementParent, _, reconciler in + createFiber: { scene, element, parent, elementParent, _, _, reconciler in Fiber( &scene, element: element, @@ -71,7 +71,7 @@ extension FiberReconciler { reconciler: reconciler ) }, - update: { fiber, scene, _ in + update: { fiber, scene, _, _ in fiber.update(with: &scene) }, visitChildren: { $1._visitChildren } @@ -82,18 +82,23 @@ extension FiberReconciler { Self.reduce( into: &partialResult, nextValue: nextView, - createFiber: { view, element, parent, elementParent, elementIndex, reconciler in + createFiber: { view, element, parent, elementParent, elementIndex, traits, reconciler in Fiber( &view, element: element, parent: parent, elementParent: elementParent, elementIndex: elementIndex, + traits: traits, reconciler: reconciler ) }, - update: { fiber, view, elementIndex in - fiber.update(with: &view, elementIndex: elementIndex) + update: { fiber, view, elementIndex, traits in + fiber.update( + with: &view, + elementIndex: elementIndex, + traits: fiber.element != nil ? traits : nil + ) }, visitChildren: { reconciler, view in reconciler?.renderer.viewVisitor(for: view) ?? view._visitChildren @@ -104,9 +109,16 @@ extension FiberReconciler { static func reduce( into partialResult: inout Result, nextValue: T, - createFiber: (inout T, Renderer.ElementType?, Fiber?, Fiber?, Int?, FiberReconciler?) - -> Fiber, - update: (Fiber, inout T, Int?) -> Renderer.ElementType.Content?, + createFiber: ( + inout T, + Renderer.ElementType?, + Fiber?, + Fiber?, + Int?, + _ViewTraitStore, + FiberReconciler? + ) -> Fiber, + update: (Fiber, inout T, Int?, _ViewTraitStore) -> Renderer.ElementType.Content?, visitChildren: (FiberReconciler?, T) -> (TreeReducer.SceneVisitor) -> () ) { // Create the node and its element. @@ -123,7 +135,8 @@ extension FiberReconciler { let newContent = update( existing, &nextValue, - key.map { partialResult.elementIndices[$0, default: 0] } + key.map { partialResult.elementIndices[$0, default: 0] }, + partialResult.pendingTraits ) resultChild = Result( fiber: existing, @@ -133,9 +146,10 @@ extension FiberReconciler { alternateChild: existing.alternate?.child, newContent: newContent, elementIndices: partialResult.elementIndices, - layoutContexts: partialResult.layoutContexts + pendingTraits: existing.element != nil ? .init() : partialResult.pendingTraits ) partialResult.nextExisting = existing.sibling + partialResult.nextExistingAlternate = partialResult.nextExistingAlternate?.sibling } else { let elementParent = partialResult.fiber?.element != nil ? partialResult.fiber @@ -153,6 +167,7 @@ extension FiberReconciler { partialResult.fiber, elementParent, key.map { partialResult.elementIndices[$0, default: 0] }, + partialResult.pendingTraits, partialResult.fiber?.reconciler ) // If a fiber already exists for an alternate, link them. @@ -167,7 +182,7 @@ extension FiberReconciler { child: nil, alternateChild: fiber.alternate?.child, elementIndices: partialResult.elementIndices, - layoutContexts: partialResult.layoutContexts + pendingTraits: fiber.element != nil ? .init() : partialResult.pendingTraits ) } // Get the last child element we've processed, and add the new child as its sibling. diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index a85c593c9..0c5089e87 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -23,14 +23,21 @@ 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 + /// Enabled passes to run on each `reconcile(from:)` call. + private let passes: [FiberReconcilerPass] + + private let caches: Caches + struct RootView: View { let content: Content let renderer: Renderer @@ -42,20 +49,51 @@ public final class FiberReconciler { } var body: some View { - content - .environmentValues(environment) + RootLayout(renderer: renderer).callAsFunction { + content + .environmentValues(environment) + } } + } - static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init( - inputs: inputs, - layoutComputer: { _ in RootLayoutComputer(sceneSize: inputs.content.renderer.sceneSize) } - ) + /// The `Layout` container for the root of a `View` hierarchy. + /// + /// Simply places each `View` in the center of its bounds. + struct RootLayout: Layout { + let renderer: Renderer + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + renderer.sceneSize + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place( + at: .init(x: bounds.midX, y: bounds.midY), + anchor: .center, + proposal: .init(width: bounds.width, height: bounds.height) + ) + } } } public init(_ renderer: Renderer, _ view: V) { self.renderer = renderer + if renderer.useDynamicLayout { + passes = [.reconcile, .layout] + } else { + passes = [.reconcile] + } + caches = Caches() var view = RootView(content: view, renderer: renderer) current = .init( &view, @@ -63,6 +101,7 @@ public final class FiberReconciler { parent: nil, elementParent: nil, elementIndex: 0, + traits: nil, reconciler: self ) // Start by building the initial tree. @@ -72,6 +111,12 @@ public final class FiberReconciler { public init(_ renderer: Renderer, _ app: A) { self.renderer = renderer + if renderer.useDynamicLayout { + passes = [.reconcile, .layout] + } else { + passes = [.reconcile] + } + caches = Caches() var environment = renderer.defaultEnvironment environment.measureText = renderer.measureText var app = app @@ -86,398 +131,80 @@ public final class FiberReconciler { reconcile(from: current) } + /// A visitor that performs each pass used by the `FiberReconciler`. final class ReconcilerVisitor: AppVisitor, SceneVisitor, ViewVisitor { - unowned let reconciler: FiberReconciler - /// The current, mounted `Fiber`. - var currentRoot: Fiber + let root: Fiber + let reconcileRoot: Fiber + unowned let reconciler: FiberReconciler var mutations = [Mutation]() - init(root: Fiber, reconciler: FiberReconciler) { + init(root: Fiber, reconcileRoot: Fiber, reconciler: FiberReconciler) { + self.root = root + self.reconcileRoot = reconcileRoot self.reconciler = reconciler - currentRoot = root } - /// A `ViewVisitor` that proposes a size for the `View` represented by the fiber `node`. - struct ProposeSizeVisitor: AppVisitor, SceneVisitor, ViewVisitor { - let node: Fiber - let renderer: Renderer - let layoutContexts: [ObjectIdentifier: LayoutContext] - - func visit(_ view: V) where V: View { - // Ask the parent what space is available. - let proposedSize = node.elementParent?.outputs.layoutComputer.proposeSize( - for: view, - at: node.elementIndex ?? 0, - in: node.elementParent.flatMap { layoutContexts[ObjectIdentifier($0)] } - ?? .init(children: []) - ) ?? .zero - // Make our layout computer using that size. - node.outputs.layoutComputer = node.outputs.makeLayoutComputer(proposedSize) - node.alternate?.outputs.layoutComputer = node.outputs.layoutComputer - } - - func visit(_ scene: S) where S: Scene { - node.outputs.layoutComputer = node.outputs.makeLayoutComputer(renderer.sceneSize) - node.alternate?.outputs.layoutComputer = node.outputs.layoutComputer - } - - func visit(_ app: A) where A: App { - node.outputs.layoutComputer = node.outputs.makeLayoutComputer(renderer.sceneSize) - node.alternate?.outputs.layoutComputer = node.outputs.layoutComputer - } - } - - func visit(_ view: V) where V: View { - visitAny(view, visitChildren: reconciler.renderer.viewVisitor(for: view)) + func visit(_ app: A) where A: App { + visitAny(app) { $0.visit(app.body) } } func visit(_ scene: S) where S: Scene { - visitAny(scene, visitChildren: scene._visitChildren) + visitAny(scene, scene._visitChildren) } - func visit(_ app: A) where A: App { - visitAny(app, visitChildren: { $0.visit(app.body) }) + func visit(_ view: V) where V: View { + visitAny(view, reconciler.renderer.viewVisitor(for: view)) } - /// 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│ - /// └───────┴────┘ - /// ``` private func visitAny( - _ value: Any, - visitChildren: @escaping (TreeReducer.SceneVisitor) -> () + _ content: Any, + _ visitChildren: @escaping (TreeReducer.SceneVisitor) -> () ) { let alternateRoot: Fiber? - if let alternate = currentRoot.alternate { + if let alternate = root.alternate { alternateRoot = alternate } else { - alternateRoot = currentRoot.createAndBindAlternate?() + alternateRoot = root.createAndBindAlternate?() + } + let alternateReconcileRoot: Fiber? + if let alternate = reconcileRoot.alternate { + alternateReconcileRoot = alternate + } else { + alternateReconcileRoot = reconcileRoot.createAndBindAlternate?() } + guard let alternateReconcileRoot = alternateReconcileRoot else { return } let rootResult = TreeReducer.Result( fiber: alternateRoot, // The alternate is the WIP node. visitChildren: visitChildren, parent: nil, child: alternateRoot?.child, - alternateChild: currentRoot.child, + alternateChild: root.child, elementIndices: [:], - layoutContexts: [:] + pendingTraits: .init() ) - 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]() - /// The `LayoutContext` for each parent view. - var layoutContexts = [ObjectIdentifier: LayoutContext]() - /// The (potentially nested) children of an `elementParent` with `element` values in order. - /// Used to position children in the correct order. - var elementChildren = [ObjectIdentifier: [Fiber]]() - - /// Compare `node` with its alternate, and add any mutations to the list. - func reconcile(_ node: TreeReducer.Result) { - if let element = node.fiber?.element, - let index = node.fiber?.elementIndex, - let parent = node.fiber?.elementParent?.element - { - 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, - geometry: node.fiber?.geometry ?? .init( - origin: .init(origin: .zero), - dimensions: .init(size: .zero, alignmentGuides: [:]) - ) - )) - } - } - } - - /// Ask the `LayoutComputer` for the fiber's `elementParent` to propose a size. - func proposeSize(for node: Fiber) { - guard node.element != nil else { return } - - // Use a visitor so we can pass the correct `View`/`Scene` type to the function. - let visitor = ProposeSizeVisitor( - node: node, - renderer: reconciler.renderer, - layoutContexts: layoutContexts + reconciler.caches.clear() + for pass in reconciler.passes { + pass.run( + in: reconciler, + root: rootResult, + reconcileRoot: alternateReconcileRoot, + caches: reconciler.caches ) - switch node.content { - case let .view(_, visit): - visit(visitor) - case let .scene(_, visit): - visit(visitor) - case let .app(_, visit): - visit(visitor) - case .none: - break - } - } - - /// Request a size from the fiber's `elementParent`. - func size(_ node: Fiber) { - guard node.element != nil, - let elementParent = node.elementParent - else { return } - - let key = ObjectIdentifier(elementParent) - let elementIndex = node.elementIndex ?? 0 - var parentContext = layoutContexts[key, default: .init(children: [])] - - // Using our LayoutComputer, compute our required size. - // This does not have to respect the elementParent's proposed size. - let size = node.outputs.layoutComputer.requestSize( - in: layoutContexts[ObjectIdentifier(node), default: .init(children: [])] - ) - let dimensions = ViewDimensions(size: size, alignmentGuides: [:]) - let child = LayoutContext.Child(index: elementIndex, dimensions: dimensions) - - // Add ourself to the parent's LayoutContext. - parentContext.children.append(child) - layoutContexts[key] = parentContext - - // Update our geometry - node.geometry = .init( - origin: node.geometry?.origin ?? .init(origin: .zero), - dimensions: dimensions - ) - } - - /// Request a position from the parent on the way back up. - func position(_ node: Fiber) { - // FIXME: Add alignmentGuide modifier to override defaults and pass the correct guide data. - guard let element = node.element, - let elementParent = node.elementParent - else { return } - - let key = ObjectIdentifier(elementParent) - let elementIndex = node.elementIndex ?? 0 - let context = layoutContexts[key, default: .init(children: [])] - - // Find our child element in our parent's LayoutContext (as added by `size(_:)`). - guard let child = context.children.first(where: { $0.index == elementIndex }) - else { return } - - // Ask our parent to position us in it's coordinate space (given our requested size). - let position = elementParent.outputs.layoutComputer.position(child, in: context) - let geometry = ViewGeometry( - origin: .init(origin: position), - dimensions: child.dimensions - ) - - // Push a layout mutation if needed. - if geometry != node.alternate?.geometry { - mutations.append(.layout(element: element, geometry: geometry)) - } - // Update ours and our alternate's geometry - node.geometry = geometry - node.alternate?.geometry = geometry - } - - /// The main reconciler loop. - func mainLoop() { - while true { - // If this fiber has an element, set its `elementIndex` - // and increment the `elementIndices` value for its `elementParent`. - if node.fiber?.element != nil, - let elementParent = node.fiber?.elementParent - { - let key = ObjectIdentifier(elementParent) - node.fiber?.elementIndex = elementIndices[key, default: 0] - elementIndices[key] = elementIndices[key, default: 0] + 1 - } - - // Perform work on the node. - reconcile(node) - - // Ensure the TreeReducer can access the `elementIndices`. - node.elementIndices = elementIndices - - // Compute the children of the node. - let reducer = TreeReducer.SceneVisitor(initialResult: node) - node.visitChildren(reducer) - - // As we walk down the tree, propose a size for each View. - if reconciler.renderer.useDynamicLayout, - let fiber = node.fiber - { - proposeSize(for: fiber) - if fiber.element != nil, - let key = fiber.elementParent.map(ObjectIdentifier.init) - { - elementChildren[key] = elementChildren[key, default: []] + [fiber] - } - } - - // 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 { - // The alternate has a child that no longer exists. - walk(alternateChild) { node in - if let element = node.element, - let parent = node.elementParent?.element - { - // 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 { - // Make sure we clear the child if there was none - node.fiber?.child = nil - node.fiber?.alternate?.child = nil - } - - // 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 - } - // We `size` and `position` when we are walking back up the tree. - if reconciler.renderer.useDynamicLayout, - let fiber = node.fiber - { - // The `elementParent` proposed a size for this fiber on the way down. - // At this point all of this fiber's children have requested sizes. - // On the way back up, we tell our elementParent what size we want, - // based on our own requirements and the sizes required by our children. - size(fiber) - // Loop through each (potentially nested) child fiber with an `element`, - // and position them in our coordinate space. This ensures children are - // positioned in order. - if let elementChildren = elementChildren[ObjectIdentifier(fiber)] { - for elementChild in elementChildren { - position(elementChild) - } - } - } - guard let parent = node.parent else { return } - // When we walk back to the root, exit - guard parent !== currentRoot.alternate else { return } - node = parent - } - - // We also request `size` and `position` when we reach the bottom-most view - // that has a sibling. - // Sizing and positioning also happen when we have no sibling, - // as seen in the above loop. - if reconciler.renderer.useDynamicLayout, - let fiber = node.fiber - { - // Request a size from our `elementParent`. - size(fiber) - // Position our children in order. - if let elementChildren = elementChildren[ObjectIdentifier(fiber)] { - for elementChild in elementChildren { - position(elementChild) - } - } - } - - // Walk across to the sibling, and repeat. - node = node.sibling! - } - } - mainLoop() - - if reconciler.renderer.useDynamicLayout { - // We continue to the very top to update all necessary positions. - var layoutNode = node.fiber?.child - while let current = layoutNode { - // We only need to re-position, because the size can't change if no state changed. - if let elementChildren = elementChildren[ObjectIdentifier(current)] { - for elementChild in elementChildren { - position(elementChild) - } - } - if current.sibling != nil { - // We also don't need to go deep into sibling children, - // because child positioning is relative to the parent. - layoutNode = current.sibling - } else { - layoutNode = current.parent - } - } } + mutations = reconciler.caches.mutations } } - func reconcile(from root: Fiber) { + func reconcile(from updateRoot: Fiber) { + let root: Fiber + if renderer.useDynamicLayout { + // We need to re-layout from the top down when using dynamic layout. + root = current + } else { + root = updateRoot + } // Create a list of mutations. - let visitor = ReconcilerVisitor(root: root, reconciler: self) + let visitor = ReconcilerVisitor(root: root, reconcileRoot: updateRoot, reconciler: self) switch root.content { case let .view(_, visit): visit(visitor) @@ -496,8 +223,14 @@ public final class FiberReconciler { // 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 + if root === current { + let alternate = alternate + self.alternate = current + current = alternate + } else { + 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 index 45d88f364..72ae8af26 100644 --- a/Sources/TokamakCore/Fiber/FiberRenderer.swift +++ b/Sources/TokamakCore/Fiber/FiberRenderer.swift @@ -37,7 +37,11 @@ public protocol FiberRenderer { /// Whether layout is enabled for this renderer. var useDynamicLayout: Bool { get } /// Calculate the size of `Text` in `environment` for layout. - func measureText(_ text: Text, proposedSize: CGSize, in environment: EnvironmentValues) -> CGSize + func measureText( + _ text: Text, + proposal: ProposedViewSize, + in environment: EnvironmentValues + ) -> CGSize } public extension FiberRenderer { @@ -58,11 +62,13 @@ public extension FiberRenderer { } @discardableResult + @_disfavoredOverload func render(_ view: V) -> FiberReconciler { .init(self, view) } @discardableResult + @_disfavoredOverload func render(_ app: A) -> FiberReconciler { .init(self, app) } @@ -70,12 +76,12 @@ public extension FiberRenderer { extension EnvironmentValues { private enum MeasureTextKey: EnvironmentKey { - static var defaultValue: (Text, CGSize, EnvironmentValues) -> CGSize { + static var defaultValue: (Text, ProposedViewSize, EnvironmentValues) -> CGSize { { _, _, _ in .zero } } } - var measureText: (Text, CGSize, EnvironmentValues) -> CGSize { + var measureText: (Text, ProposedViewSize, EnvironmentValues) -> CGSize { get { self[MeasureTextKey.self] } set { self[MeasureTextKey.self] = newValue } } diff --git a/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift deleted file mode 100644 index b4552bd98..000000000 --- a/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift +++ /dev/null @@ -1,85 +0,0 @@ -// 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 5/24/22. -// - -import Foundation - -/// A `LayoutComputer` that constrains a background to a foreground. -final class BackgroundLayoutComputer: LayoutComputer { - let proposedSize: CGSize - let alignment: Alignment - - init(proposedSize: CGSize, alignment: Alignment) { - self.proposedSize = proposedSize - self.alignment = alignment - } - - func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - if index == 0 { - // The foreground can pick their size. - return proposedSize - } else { - // The background is constrained to the foreground. - return context.children.first?.dimensions.size ?? .zero - } - } - - func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - let foregroundSize = ViewDimensions( - size: .init( - width: context.children.first?.dimensions.width ?? 0, - height: context.children.first?.dimensions.height ?? 0 - ), - alignmentGuides: [:] - ) - return .init( - x: foregroundSize[alignment.horizontal] - child.dimensions[alignment.horizontal], - y: foregroundSize[alignment.vertical] - child.dimensions[alignment.vertical] - ) - } - - func requestSize(in context: LayoutContext) -> CGSize { - let childSize = context.children.reduce(CGSize.zero) { - .init( - width: max($0.width, $1.dimensions.width), - height: max($0.height, $1.dimensions.height) - ) - } - return .init(width: childSize.width, height: childSize.height) - } -} - -public extension _BackgroundLayout { - static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init( - inputs: inputs, - layoutComputer: { - BackgroundLayoutComputer(proposedSize: $0, alignment: inputs.content.alignment) - } - ) - } -} - -public extension _BackgroundStyleModifier { - static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init( - inputs: inputs, - layoutComputer: { BackgroundLayoutComputer(proposedSize: $0, alignment: .center) } - ) - } -} diff --git a/Sources/TokamakCore/Fiber/Layout/ContainedZLayout.swift b/Sources/TokamakCore/Fiber/Layout/ContainedZLayout.swift new file mode 100644 index 000000000..6bc701af0 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/ContainedZLayout.swift @@ -0,0 +1,136 @@ +// Copyright 2022 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 6/20/22. +// + +import Foundation + +/// The cache for a `ContainedZLayout`. +@_spi(TokamakCore) +public struct ContainedZLayoutCache { + /// The result of `dimensions(in:)` for the primary subview. + var primaryDimensions: ViewDimensions? +} + +/// A layout that fits secondary subviews to the size of a primary subview. +/// +/// Used to implement `_BackgroundLayout` and `_OverlayLayout`. +@_spi(TokamakCore) +public protocol ContainedZLayout: Layout where Cache == ContainedZLayoutCache { + var alignment: Alignment { get } + /// An accessor for the primary subview from a `LayoutSubviews` collection. + static var primarySubview: KeyPath { get } +} + +@_spi(TokamakCore) +public extension ContainedZLayout { + func makeCache(subviews: Subviews) -> Cache { + .init() + } + + func spacing(subviews: LayoutSubviews, cache: inout Cache) -> ViewSpacing { + subviews[keyPath: Self.primarySubview]?.spacing ?? .init() + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + // Assume the dimensions of the primary subview. + cache.primaryDimensions = subviews[keyPath: Self.primarySubview]?.dimensions(in: proposal) + return .init( + width: cache.primaryDimensions?.width ?? .zero, + height: cache.primaryDimensions?.height ?? .zero + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + let proposal = ProposedViewSize(bounds.size) + + // Place the foreground at the origin. + subviews[keyPath: Self.primarySubview]?.place(at: bounds.origin, proposal: proposal) + + let backgroundSubviews = subviews[keyPath: Self.primarySubview] == subviews.first + ? subviews.dropFirst(1) + : subviews.dropLast(1) + + /// The `ViewDimensions` of the subview with the greatest `width`, used to follow `alignment`. + var widest: ViewDimensions? + /// The `ViewDimensions` of the subview with the greatest `height`. + var tallest: ViewDimensions? + + let dimensions = backgroundSubviews.map { subview -> ViewDimensions in + let dimensions = subview.dimensions(in: proposal) + if dimensions.width > (widest?.width ?? .zero) { + widest = dimensions + } + if dimensions.height > (tallest?.height ?? .zero) { + tallest = dimensions + } + return dimensions + } + + /// The alignment guide values of the primary subview. + let primaryOffset = CGSize( + width: cache.primaryDimensions?[alignment.horizontal] ?? .zero, + height: cache.primaryDimensions?[alignment.vertical] ?? .zero + ) + /// The alignment guide values of the secondary subviews (background/overlay). + /// Uses the widest/tallest element to get the full extents. + let secondaryOffset = CGSize( + width: widest?[alignment.horizontal] ?? .zero, + height: tallest?[alignment.vertical] ?? .zero + ) + /// The center offset of the secondary subviews. + let secondaryCenter = CGSize( + width: widest?[HorizontalAlignment.center] ?? .zero, + height: tallest?[VerticalAlignment.center] ?? .zero + ) + /// The origin of the secondary subviews with alignment. + let secondaryOrigin = CGPoint( + x: bounds.minX + primaryOffset.width - secondaryOffset.width + secondaryCenter.width, + y: bounds.minY + primaryOffset.height - secondaryOffset.height + secondaryCenter.height + ) + for (index, subview) in backgroundSubviews.enumerated() { + // Background elements are centered between each other, but placed with `alignment` + // all together on the foreground. + subview.place( + at: .init( + x: secondaryOrigin.x - dimensions[index][HorizontalAlignment.center], + y: secondaryOrigin.y - dimensions[index][VerticalAlignment.center] + ), + proposal: proposal + ) + } + } +} + +/// Expects the primary subview to be last. +@_spi(TokamakCore) +extension _BackgroundLayout: ContainedZLayout { + public static var primarySubview: KeyPath { \.last } +} + +/// Expects the primary subview to be the first. +@_spi(TokamakCore) +extension _OverlayLayout: ContainedZLayout { + public static var primarySubview: KeyPath { \.first } +} diff --git a/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift deleted file mode 100644 index 2f67f0448..000000000 --- a/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2022 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 5/28/22. -// - -import Foundation - -/// A `LayoutComputer` that uses a specified size in one or more axes. -@_spi(TokamakCore) -public struct FrameLayoutComputer: LayoutComputer { - let proposedSize: CGSize - let width: CGFloat? - let height: CGFloat? - let alignment: Alignment - - public init(proposedSize: CGSize, width: CGFloat?, height: CGFloat?, alignment: Alignment) { - self.proposedSize = proposedSize - self.width = width - self.height = height - self.alignment = alignment - } - - public func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - .init(width: width ?? proposedSize.width, height: height ?? proposedSize.height) - } - - public func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - let size = ViewDimensions( - size: .init( - width: width ?? child.dimensions.width, - height: height ?? child.dimensions.height - ), - alignmentGuides: [:] - ) - return .init( - x: size[alignment.horizontal] - child.dimensions[alignment.horizontal], - y: size[alignment.vertical] - child.dimensions[alignment.vertical] - ) - } - - public func requestSize(in context: LayoutContext) -> CGSize { - let childSize = context.children.reduce(CGSize.zero) { - .init( - width: max($0.width, $1.dimensions.width), - height: max($0.height, $1.dimensions.height) - ) - } - return .init(width: width ?? childSize.width, height: height ?? childSize.height) - } -} - -public extension _FrameLayout { - static func _makeView(_ inputs: ViewInputs<_FrameLayout>) -> ViewOutputs { - .init(inputs: inputs, layoutComputer: { FrameLayoutComputer( - proposedSize: $0, - width: inputs.content.width, - height: inputs.content.height, - alignment: inputs.content.alignment - ) }) - } -} diff --git a/Sources/TokamakCore/Fiber/Layout/Layout.swift b/Sources/TokamakCore/Fiber/Layout/Layout.swift new file mode 100644 index 000000000..dc7a7bff4 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/Layout.swift @@ -0,0 +1,477 @@ +// Copyright 2022 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 + +/// Erase a `Layout` conformance to an `AnyLayout`. +/// +/// This could potentially be removed in Swift 5.7 in favor of `any Layout`. +public protocol _AnyLayout { + func _erased() -> AnyLayout +} + +/// A type that participates in the layout pass. +/// +/// Any `View` or `Scene` that implements this protocol will be used to compute layout in +/// a `FiberRenderer` with `useDynamicLayout` set to `true`. +public protocol Layout: Animatable, _AnyLayout { + static var layoutProperties: LayoutProperties { get } + + associatedtype Cache = () + + /// Proxies for the children of this container. + typealias Subviews = LayoutSubviews + + /// Create a fresh `Cache`. Use it to store complex operations, + /// or to pass data between `sizeThatFits` and `placeSubviews`. + /// + /// - Note: There are no guarantees about when the cache will be recreated, + /// and the behavior could change at any time. + func makeCache(subviews: Self.Subviews) -> Self.Cache + + /// Update the existing `Cache` before each layout pass. + func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews) + + /// The preferred spacing for this `View` and its subviews. + func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing + + /// Request a size to contain the subviews and fit within `proposal`. + /// If you provide a size that does not fit within `proposal`, the parent will still respect it. + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGSize + + /// Place each subview with `LayoutSubview.place(at:anchor:proposal:)`. + /// + /// - Note: The bounds are not necessarily at `(0, 0)`, so use `bounds.minX` and `bounds.minY` + /// to correctly position relative to the container. + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) + + /// Override the value of a `HorizontalAlignment` value. + func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? + + /// Override the value of a `VerticalAlignment` value. + func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? +} + +public extension Layout { + func _erased() -> AnyLayout { + .init(self) + } +} + +public extension Layout where Self.Cache == () { + func makeCache(subviews: Self.Subviews) -> Self.Cache { + () + } +} + +public extension Layout { + static var layoutProperties: LayoutProperties { + .init() + } + + func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews) { + cache = makeCache(subviews: subviews) + } + + func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing { + subviews.reduce( + into: subviews.first.map { + .init( + viewType: $0.spacing.viewType, + top: { _ in 0 }, + leading: { _ in 0 }, + bottom: { _ in 0 }, + trailing: { _ in 0 } + ) + } ?? .zero + ) { $0.formUnion($1.spacing) } + } + + func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? { + nil + } + + func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? { + nil + } +} + +public extension Layout { + /// Render `content` using `self` as the layout container. + func callAsFunction(@ViewBuilder _ content: () -> V) -> some View where V: View { + LayoutView(layout: self, content: content()) + } +} + +/// A `View` that renders its children with a `Layout`. +@_spi(TokamakCore) +public struct LayoutView: View, Layout { + let layout: L + let content: Content + + public typealias Cache = L.Cache + + public func makeCache(subviews: Subviews) -> L.Cache { + layout.makeCache(subviews: subviews) + } + + public func updateCache(_ cache: inout L.Cache, subviews: Subviews) { + layout.updateCache(&cache, subviews: subviews) + } + + public func spacing(subviews: Subviews, cache: inout L.Cache) -> ViewSpacing { + layout.spacing(subviews: subviews, cache: &cache) + } + + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + layout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + layout.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &cache) + } + + public func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout L.Cache + ) -> CGFloat? { + layout.explicitAlignment( + of: guide, in: bounds, proposal: proposal, subviews: subviews, cache: &cache + ) + } + + public func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout L.Cache + ) -> CGFloat? { + layout.explicitAlignment( + of: guide, in: bounds, proposal: proposal, subviews: subviews, cache: &cache + ) + } + + public var body: some View { + content + } +} + +/// A default `Layout` that fits to the first subview and places its children at its origin. +struct DefaultLayout: Layout { + /// An erased `DefaultLayout` that is shared between all views. + static let shared: AnyLayout = .init(Self()) + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let size = subviews.first?.sizeThatFits(proposal) ?? .zero + return size + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place(at: bounds.origin, proposal: proposal) + } + } +} + +/// Describes a container for an erased `Layout` type. +/// +/// Matches the `Layout` protocol with `Cache` erased to `Any`. +@usableFromInline +protocol AnyLayoutBox: AnyObject { + var layoutProperties: LayoutProperties { get } + + typealias Subviews = LayoutSubviews + typealias Cache = Any + + func makeCache(subviews: Self.Subviews) -> Self.Cache + + func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews) + + func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGSize + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) + + func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? + + func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? + + var animatableData: _AnyAnimatableData { get set } +} + +final class ConcreteLayoutBox: AnyLayoutBox { + var base: L + + init(_ base: L) { + self.base = base + } + + var layoutProperties: LayoutProperties { L.layoutProperties } + + func makeCache(subviews: Subviews) -> Cache { + base.makeCache(subviews: subviews) + } + + private func typedCache( + subviews: Subviews, + erasedCache: inout Cache, + _ action: (inout L.Cache) -> R + ) -> R { + var typedCache = erasedCache as? L.Cache ?? base.makeCache(subviews: subviews) + defer { erasedCache = typedCache } + return action(&typedCache) + } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + typedCache(subviews: subviews, erasedCache: &cache) { + base.updateCache(&$0, subviews: subviews) + } + } + + func spacing(subviews: Subviews, cache: inout Cache) -> ViewSpacing { + typedCache(subviews: subviews, erasedCache: &cache) { + base.spacing(subviews: subviews, cache: &$0) + } + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + typedCache(subviews: subviews, erasedCache: &cache) { + base.sizeThatFits(proposal: proposal, subviews: subviews, cache: &$0) + } + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + typedCache(subviews: subviews, erasedCache: &cache) { + base.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &$0) + } + } + + func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGFloat? { + typedCache(subviews: subviews, erasedCache: &cache) { + base.explicitAlignment( + of: guide, + in: bounds, + proposal: proposal, + subviews: subviews, + cache: &$0 + ) + } + } + + func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGFloat? { + typedCache(subviews: subviews, erasedCache: &cache) { + base.explicitAlignment( + of: guide, + in: bounds, + proposal: proposal, + subviews: subviews, + cache: &$0 + ) + } + } + + var animatableData: _AnyAnimatableData { + get { + .init(base.animatableData) + } + set { + guard let newData = newValue.value as? L.AnimatableData else { return } + base.animatableData = newData + } + } +} + +@frozen +public struct AnyLayout: Layout { + var storage: AnyLayoutBox + + public init(_ layout: L) where L: Layout { + storage = ConcreteLayoutBox(layout) + } + + public struct Cache { + var erasedCache: Any + } + + public func makeCache(subviews: AnyLayout.Subviews) -> AnyLayout.Cache { + .init(erasedCache: storage.makeCache(subviews: subviews)) + } + + public func updateCache(_ cache: inout AnyLayout.Cache, subviews: AnyLayout.Subviews) { + storage.updateCache(&cache.erasedCache, subviews: subviews) + } + + public func spacing(subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) -> ViewSpacing { + storage.spacing(subviews: subviews, cache: &cache.erasedCache) + } + + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: AnyLayout.Subviews, + cache: inout AnyLayout.Cache + ) -> CGSize { + storage.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache.erasedCache) + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: AnyLayout.Subviews, + cache: inout AnyLayout.Cache + ) { + storage.placeSubviews( + in: bounds, + proposal: proposal, + subviews: subviews, + cache: &cache.erasedCache + ) + } + + public func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: AnyLayout.Subviews, + cache: inout AnyLayout.Cache + ) -> CGFloat? { + storage.explicitAlignment( + of: guide, + in: bounds, + proposal: proposal, + subviews: subviews, + cache: &cache.erasedCache + ) + } + + public func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: AnyLayout.Subviews, + cache: inout AnyLayout.Cache + ) -> CGFloat? { + storage.explicitAlignment( + of: guide, in: bounds, + proposal: proposal, + subviews: subviews, + cache: &cache.erasedCache + ) + } + + public var animatableData: _AnyAnimatableData { + get { + _AnyAnimatableData(storage.animatableData) + } + set { + storage.animatableData = newValue + } + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/LayoutPriority.swift similarity index 51% rename from Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift rename to Sources/TokamakCore/Fiber/Layout/LayoutPriority.swift index 4dd3b63c2..079ceeac4 100644 --- a/Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/Layout/LayoutPriority.swift @@ -12,31 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. // -// Created by Carson Katri on 5/24/22. +// Created by Carson Katri on 6/22/22. // import Foundation -/// A `LayoutComputer` that fills its parent. -@_spi(TokamakCore) -public struct FlexLayoutComputer: LayoutComputer { - let proposedSize: CGSize - - public init(proposedSize: CGSize) { - self.proposedSize = proposedSize - } - - public func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - proposedSize - } - - public func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - .zero - } +@usableFromInline +enum LayoutPriorityTraitKey: _ViewTraitKey { + @inlinable + static var defaultValue: Double { 0 } +} - public func requestSize(in context: LayoutContext) -> CGSize { - proposedSize +public extension View { + @inlinable + func layoutPriority(_ value: Double) -> some View { + _trait(LayoutPriorityTraitKey.self, value) } } diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutProperties.swift b/Sources/TokamakCore/Fiber/Layout/LayoutProperties.swift new file mode 100644 index 000000000..f0d545dce --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/LayoutProperties.swift @@ -0,0 +1,25 @@ +// Copyright 2022 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 6/20/22. +// + +/// Metadata about a `Layout`. +public struct LayoutProperties { + public var stackOrientation: Axis? + + public init() { + stackOrientation = nil + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift new file mode 100644 index 000000000..b71ce34e7 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift @@ -0,0 +1,251 @@ +// Copyright 2022 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 6/20/22. +// + +import Foundation + +/// A collection of `LayoutSubview` proxies. +public struct LayoutSubviews: Equatable, RandomAccessCollection { + public var layoutDirection: LayoutDirection + var storage: [LayoutSubview] + + init(layoutDirection: LayoutDirection, storage: [LayoutSubview]) { + self.layoutDirection = layoutDirection + self.storage = storage + } + + init(_ node: FiberReconciler.Fiber) { + self.init( + layoutDirection: node.outputs.environment.environment.layoutDirection, + storage: [] + ) + } + + public typealias SubSequence = LayoutSubviews + public typealias Element = LayoutSubview + public typealias Index = Int + public typealias Indices = Range + public typealias Iterator = IndexingIterator + + public var startIndex: Int { + storage.startIndex + } + + public var endIndex: Int { + storage.endIndex + } + + public subscript(index: Int) -> LayoutSubviews.Element { + storage[index] + } + + public subscript(bounds: Range) -> LayoutSubviews { + .init(layoutDirection: layoutDirection, storage: .init(storage[bounds])) + } + + public subscript(indices: S) -> LayoutSubviews where S: Sequence, S.Element == Int { + .init( + layoutDirection: layoutDirection, + storage: storage.enumerated() + .filter { indices.contains($0.offset) } + .map(\.element) + ) + } +} + +/// A proxy representing a child of a `Layout`. +/// +/// Access size requests, alignment guide values, spacing preferences, and any layout values using +/// this proxy. +/// +/// `Layout` types are expected to call `place(at:anchor:proposal:)` on all subviews. +/// If `place(at:anchor:proposal:)` is not called, the center will be used as its position. +public struct LayoutSubview: Equatable { + private let id: ObjectIdentifier + private let storage: AnyStorage + + /// A protocol used to erase `Storage`. + private class AnyStorage { + let traits: _ViewTraitStore? + + init(traits: _ViewTraitStore?) { + self.traits = traits + } + + func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { + fatalError("Implement \(#function) in subclass") + } + + func dimensions(_ sizeThatFits: CGSize) -> ViewDimensions { + fatalError("Implement \(#function) in subclass") + } + + func place( + _ proposal: ProposedViewSize, + _ dimensions: ViewDimensions, + _ position: CGPoint, + _ anchor: UnitPoint + ) { + fatalError("Implement \(#function) in subclass") + } + + func spacing() -> ViewSpacing { + fatalError("Implement \(#function) in subclass") + } + } + + /// The backing storage for a `LayoutSubview`. This contains the underlying implementations for + /// methods accessing the `fiber`, `element`, and `cache` this subview represents. + private final class Storage: AnyStorage { + weak var fiber: FiberReconciler.Fiber? + weak var element: R.ElementType? + unowned var caches: FiberReconciler.Caches + + init( + traits: _ViewTraitStore?, + fiber: FiberReconciler.Fiber?, + element: R.ElementType?, + caches: FiberReconciler.Caches + ) { + self.fiber = fiber + self.element = element + self.caches = caches + super.init(traits: traits) + } + + override func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { + guard let fiber = fiber else { return .zero } + let request = FiberReconciler.Caches.LayoutCache.SizeThatFitsRequest(proposal) + return caches.updateLayoutCache(for: fiber) { cache in + guard let layout = fiber.layout else { return .zero } + if let size = cache.sizeThatFits[request] { + return size + } else { + let size = layout.sizeThatFits( + proposal: proposal, + subviews: caches.layoutSubviews(for: fiber), + cache: &cache.cache + ) + cache.sizeThatFits[request] = size + if let alternate = fiber.alternate { + caches.updateLayoutCache(for: alternate) { cache in + cache.sizeThatFits[request] = size + } + } + return size + } + } ?? .zero + } + + override func dimensions(_ sizeThatFits: CGSize) -> ViewDimensions { + // TODO: Add `alignmentGuide` modifier and pass into `ViewDimensions` + ViewDimensions(size: sizeThatFits, alignmentGuides: [:]) + } + + override func place( + _ proposal: ProposedViewSize, + _ dimensions: ViewDimensions, + _ position: CGPoint, + _ anchor: UnitPoint + ) { + guard let fiber = fiber, let element = element else { return } + let geometry = ViewGeometry( + // Shift to the anchor point in the parent's coordinate space. + origin: .init(origin: .init( + x: position.x - (dimensions.width * anchor.x), + y: position.y - (dimensions.height * anchor.y) + )), + dimensions: dimensions, + proposal: proposal + ) + // Push a layout mutation if needed. + if geometry != fiber.alternate?.geometry { + caches.mutations.append(.layout(element: element, geometry: geometry)) + } + // Update ours and our alternate's geometry + fiber.geometry = geometry + fiber.alternate?.geometry = geometry + } + + override func spacing() -> ViewSpacing { + guard let fiber = fiber else { return .init() } + + return caches.updateLayoutCache(for: fiber) { cache in + fiber.layout?.spacing( + subviews: caches.layoutSubviews(for: fiber), + cache: &cache.cache + ) ?? .zero + } ?? .zero + } + } + + init( + id: ObjectIdentifier, + traits: _ViewTraitStore?, + fiber: FiberReconciler.Fiber, + element: R.ElementType, + caches: FiberReconciler.Caches + ) { + self.id = id + storage = Storage( + traits: traits, + fiber: fiber, + element: element, + caches: caches + ) + } + + public func _trait(key: K.Type) -> K.Value where K: _ViewTraitKey { + storage.traits?.value(forKey: key) ?? K.defaultValue + } + + public subscript(key: K.Type) -> K.Value where K: LayoutValueKey { + _trait(key: _LayoutTrait.self) + } + + public var priority: Double { + _trait(key: LayoutPriorityTraitKey.self) + } + + public func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { + storage.sizeThatFits(proposal) + } + + public func dimensions(in proposal: ProposedViewSize) -> ViewDimensions { + storage.dimensions(sizeThatFits(proposal)) + } + + public var spacing: ViewSpacing { + storage.spacing() + } + + public func place( + at position: CGPoint, + anchor: UnitPoint = .topLeading, + proposal: ProposedViewSize + ) { + storage.place( + proposal, + dimensions(in: proposal), + position, + anchor + ) + } + + public static func == (lhs: LayoutSubview, rhs: LayoutSubview) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutValueKey.swift b/Sources/TokamakCore/Fiber/Layout/LayoutValueKey.swift new file mode 100644 index 000000000..5c334ad3c --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/LayoutValueKey.swift @@ -0,0 +1,36 @@ +// Copyright 2022 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 6/20/22. +// + +/// A key that stores a value that can be accessed via a `LayoutSubview`. +public protocol LayoutValueKey { + associatedtype Value + static var defaultValue: Self.Value { get } +} + +public extension View { + @inlinable + func layoutValue(key: K.Type, value: K.Value) -> some View where K: LayoutValueKey { + // LayoutValueKey uses trait keys under the hood. + _trait(_LayoutTrait.self, value) + } +} + +public struct _LayoutTrait: _ViewTraitKey where K: LayoutValueKey { + public static var defaultValue: K.Value { + K.defaultValue + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift new file mode 100644 index 000000000..0a35abfde --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift @@ -0,0 +1,84 @@ +// Copyright 2022 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 5/28/22. +// + +import Foundation + +private extension EdgeInsets { + init(applying edges: Edge.Set, to insets: EdgeInsets) { + self.init( + top: edges.contains(.top) ? insets.top : 0, + leading: edges.contains(.leading) ? insets.leading : 0, + bottom: edges.contains(.bottom) ? insets.bottom : 0, + trailing: edges.contains(.trailing) ? insets.trailing : 0 + ) + } +} + +private struct PaddingLayout: Layout { + let edges: Edge.Set + let insets: EdgeInsets? + + func spacing(subviews: Subviews, cache: inout ()) -> ViewSpacing { + .init() + } + + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let proposal = proposal.replacingUnspecifiedDimensions() + let insets = EdgeInsets(applying: edges, to: insets ?? .init(_all: 10)) + let subviewSize = subviews.first?.sizeThatFits( + .init( + width: proposal.width - insets.leading - insets.trailing, + height: proposal.height - insets.top - insets.bottom + ) + ) ?? .zero + return .init( + width: subviewSize.width + insets.leading + insets.trailing, + height: subviewSize.height + insets.top + insets.bottom + ) + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let insets = EdgeInsets(applying: edges, to: insets ?? .init(_all: 10)) + let proposal = proposal.replacingUnspecifiedDimensions() + for subview in subviews { + subview.place( + at: .init(x: bounds.minX + insets.leading, y: bounds.minY + insets.top), + proposal: .init( + width: proposal.width - insets.leading - insets.trailing, + height: proposal.height - insets.top - insets.bottom + ) + ) + } + } +} + +public extension _PaddingLayout { + func _visitChildren(_ visitor: V, content: Content) where V: ViewVisitor { + visitor.visit(PaddingLayout(edges: edges, insets: insets).callAsFunction { + content + }) + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/PaddingLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/PaddingLayoutComputer.swift deleted file mode 100644 index 8904e6fb5..000000000 --- a/Sources/TokamakCore/Fiber/Layout/PaddingLayoutComputer.swift +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2022 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 5/28/22. -// - -import Foundation - -private extension EdgeInsets { - init(applying edges: Edge.Set, to insets: EdgeInsets) { - self.init( - top: edges.contains(.top) ? insets.top : 0, - leading: edges.contains(.leading) ? insets.leading : 0, - bottom: edges.contains(.bottom) ? insets.bottom : 0, - trailing: edges.contains(.trailing) ? insets.trailing : 0 - ) - } -} - -/// A `LayoutComputer` that fits to its children then adds padding. -struct PaddingLayoutComputer: LayoutComputer { - let proposedSize: CGSize - let insets: EdgeInsets - - init(proposedSize: CGSize, edges: Edge.Set, insets: EdgeInsets?) { - self.proposedSize = proposedSize - self.insets = .init(applying: edges, to: insets ?? EdgeInsets(_all: 10)) - } - - func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - .init( - width: proposedSize.width - insets.leading - insets.trailing, - height: proposedSize.height - insets.top - insets.bottom - ) - } - - func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - .init( - x: insets.leading, - y: insets.top - ) - } - - func requestSize(in context: LayoutContext) -> CGSize { - let childSize = context.children.reduce(CGSize.zero) { - .init( - width: max($0.width, $1.dimensions.width), - height: max($0.height, $1.dimensions.height) - ) - } - return .init( - width: childSize.width + insets.leading + insets.trailing, - height: childSize.height + insets.top + insets.bottom - ) - } -} - -public extension _PaddingLayout { - static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init( - inputs: inputs, - layoutComputer: { - PaddingLayoutComputer( - proposedSize: $0, - edges: inputs.content.edges, - insets: inputs.content.insets - ) - } - ) - } -} diff --git a/Sources/TokamakCore/Layout/ProposedViewSize.swift b/Sources/TokamakCore/Fiber/Layout/ProposedViewSize.swift similarity index 57% rename from Sources/TokamakCore/Layout/ProposedViewSize.swift rename to Sources/TokamakCore/Fiber/Layout/ProposedViewSize.swift index 5920db3b6..22798c911 100644 --- a/Sources/TokamakCore/Layout/ProposedViewSize.swift +++ b/Sources/TokamakCore/Fiber/Layout/ProposedViewSize.swift @@ -11,39 +11,34 @@ // 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 6/20/22. +// import Foundation -public struct ProposedViewSize: Equatable, Sendable { +@frozen +public struct ProposedViewSize: Equatable { public var width: CGFloat? public var height: CGFloat? - + public static let zero: ProposedViewSize = .init(width: 0, height: 0) + public static let unspecified: ProposedViewSize = .init(width: nil, height: nil) + public static let infinity: ProposedViewSize = .init(width: .infinity, height: .infinity) @inlinable public init(width: CGFloat?, height: CGFloat?) { - self.width = width - self.height = height + (self.width, self.height) = (width, height) } -} -public extension ProposedViewSize { @inlinable - init(_ size: CGSize) { + public init(_ size: CGSize) { self.init(width: size.width, height: size.height) } - static let unspecified = ProposedViewSize(width: nil, height: nil) - static let zero = ProposedViewSize(width: 0, height: 0) - static let infinity = ProposedViewSize(width: .infinity, height: .infinity) -} - -public extension ProposedViewSize { @inlinable - func replacingUnspecifiedDimensions( - by size: CGSize = CGSize(width: 10, height: 10) - ) -> CGSize { - CGSize( - width: width ?? size.width, - height: height ?? size.height - ) + public func replacingUnspecifiedDimensions(by size: CGSize = CGSize( + width: 10, + height: 10 + )) -> CGSize { + CGSize(width: width ?? size.width, height: height ?? size.height) } } diff --git a/Sources/TokamakCore/Fiber/Layout/RootLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/RootLayoutComputer.swift deleted file mode 100644 index 7e6c21499..000000000 --- a/Sources/TokamakCore/Fiber/Layout/RootLayoutComputer.swift +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2022 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 5/28/22. -// - -import Foundation - -/// A `LayoutComputer` for the root element of a `FiberRenderer`. -struct RootLayoutComputer: LayoutComputer { - let sceneSize: CGSize - - init(sceneSize: CGSize) { - self.sceneSize = sceneSize - } - - func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - sceneSize - } - - func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - .init( - x: sceneSize.width / 2 - child.dimensions[HorizontalAlignment.center], - y: sceneSize.height / 2 - child.dimensions[VerticalAlignment.center] - ) - } - - func requestSize(in context: LayoutContext) -> CGSize { - sceneSize - } -} diff --git a/Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift deleted file mode 100644 index 92df4fa1d..000000000 --- a/Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2022 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 5/24/22. -// - -import Foundation - -/// A `LayoutComputer` that shrinks to the size of its children. -@_spi(TokamakCore) -public struct ShrinkWrapLayoutComputer: LayoutComputer { - let proposedSize: CGSize - - public init(proposedSize: CGSize) { - self.proposedSize = proposedSize - } - - public func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - proposedSize - } - - public func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - .zero - } - - public func requestSize(in context: LayoutContext) -> CGSize { - context.children.reduce(CGSize.zero) { - .init( - width: max($0.width, $1.dimensions.width), - height: max($0.height, $1.dimensions.height) - ) - } - } -} diff --git a/Sources/TokamakCore/Fiber/Layout/StackLayout.swift b/Sources/TokamakCore/Fiber/Layout/StackLayout.swift new file mode 100644 index 000000000..b8da37c32 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/StackLayout.swift @@ -0,0 +1,291 @@ +// Copyright 2022 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 5/24/22. +// + +import Foundation + +private extension ViewDimensions { + /// Access the guide value of an `Alignment` for a particular `Axis`. + subscript(alignment alignment: Alignment, in axis: Axis) -> CGFloat { + switch axis { + case .horizontal: return self[alignment.vertical] + case .vertical: return self[alignment.horizontal] + } + } +} + +/// The `Layout.Cache` for `StackLayout` conforming types. +@_spi(TokamakCore) +public struct StackLayoutCache { + /// The widest/tallest (depending on the `axis`) subview. + /// Used to place subviews along the `alignment`. + var maxSubview: ViewDimensions? + + /// The ideal size for each subview as computed in `sizeThatFits`. + var idealSizes = [CGSize]() +} + +/// An internal structure used to store layout information about +/// `LayoutSubview`s of a `StackLayout` that will later be sorted +private struct MeasuredSubview { + let view: LayoutSubview + let index: Int + let min: CGSize + let max: CGSize + let infiniteMainAxis: Bool + let spacing: CGFloat +} + +/// The protocol all built-in stacks conform to. +/// Provides a shared implementation for stack layout logic. +@_spi(TokamakCore) +public protocol StackLayout: Layout where Cache == StackLayoutCache { + /// The direction of this stack. `vertical` for `VStack`, `horizontal` for `HStack`. + static var orientation: Axis { get } + + /// The full `Alignment` with an ignored value for the main axis. + var _alignment: Alignment { get } + + var spacing: CGFloat? { get } +} + +@_spi(TokamakCore) +public extension StackLayout { + static var layoutProperties: LayoutProperties { + var properties = LayoutProperties() + properties.stackOrientation = Self.orientation + return properties + } + + /// The `CGSize` component for the current `axis`. + /// + /// A `vertical` axis will return `height`. + /// A `horizontal` axis will return `width`. + static var mainAxis: WritableKeyPath { + switch Self.orientation { + case .vertical: return \.height + case .horizontal: return \.width + } + } + + /// The `CGSize` component for the axis opposite `axis`. + /// + /// A `vertical` axis will return `width`. + /// A `horizontal` axis will return `height`. + static var crossAxis: WritableKeyPath { + switch Self.orientation { + case .vertical: return \.width + case .horizontal: return \.height + } + } + + func makeCache(subviews: Subviews) -> Cache { + // Ensure we have enough space in `idealSizes` for each subview. + .init(maxSubview: nil, idealSizes: Array(repeating: .zero, count: subviews.count)) + } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + cache.maxSubview = nil + // Ensure we have enough space in `idealSizes` for each subview. + cache.idealSizes = Array(repeating: .zero, count: subviews.count) + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { + let proposal = proposal.replacingUnspecifiedDimensions() + + /// The minimum size of each `View` on the main axis. + var minSize = CGFloat.zero + + /// The aggregate `ViewSpacing` distances. + var totalSpacing = CGFloat.zero + + /// The number of `View`s with a given priority. + var priorityCount = [Double: Int]() + + /// The aggregate minimum size of each `View` with a given priority. + var prioritySize = [Double: CGFloat]() + + let measuredSubviews = subviews.enumerated().map { index, view -> MeasuredSubview in + priorityCount[view.priority, default: 0] += 1 + + var minProposal = CGSize(width: CGFloat.infinity, height: CGFloat.infinity) + minProposal[keyPath: Self.crossAxis] = proposal[keyPath: Self.crossAxis] + minProposal[keyPath: Self.mainAxis] = 0 + /// The minimum size for this subview along the `mainAxis`. + /// Uses `dimensions(in:)` to collect the alignment guides for use in `placeSubviews`. + let min = view.dimensions(in: .init(minProposal)) + + // Aggregate the minimum size of the stack for the combined subviews. + minSize += min.size[keyPath: Self.mainAxis] + + // Aggregate the minimum size of this priority to divvy up space later. + prioritySize[view.priority, default: 0] += min.size[keyPath: Self.mainAxis] + + var maxProposal = CGSize(width: CGFloat.infinity, height: CGFloat.infinity) + maxProposal[keyPath: Self.crossAxis] = minProposal[keyPath: Self.crossAxis] + /// The maximum size for this subview along the `mainAxis`. + let max = view.sizeThatFits(.init(maxProposal)) + + /// The spacing around this `View` and its previous (if it is not first). + let spacing: CGFloat + if subviews.indices.contains(index - 1) { + if let overrideSpacing = self.spacing { + spacing = overrideSpacing + } else { + spacing = subviews[index - 1].spacing.distance(to: view.spacing, along: Self.orientation) + } + } else { + spacing = .zero + } + // Aggregate all spacing values. + totalSpacing += spacing + + // If this `View` is the widest, save it to the cache for access in `placeSubviews`. + if min.size[keyPath: Self.crossAxis] > cache.maxSubview?.size[keyPath: Self.crossAxis] + ?? .zero + { + cache.maxSubview = min + } + + return MeasuredSubview( + view: view, + index: index, + min: min.size, + max: max, + infiniteMainAxis: max[keyPath: Self.mainAxis] == .infinity, + spacing: spacing + ) + } + + // Calculate ideal sizes for each View based on their min/max sizes and the space available. + var available = proposal[keyPath: Self.mainAxis] - minSize - totalSpacing + // The final resulting size. + var size = CGSize.zero + size[keyPath: Self.crossAxis] = cache.maxSubview?.size[keyPath: Self.crossAxis] ?? .zero + for subview in measuredSubviews.sorted(by: { + // Sort by priority descending. + if $0.view.priority == $1.view.priority { + // If the priorities match, allow non-flexible `View`s to size first. + return $1.infiniteMainAxis && !$0.infiniteMainAxis + } else { + return $0.view.priority > $1.view.priority + } + }) { + // The amount of space available to `View`s with this priority value. + let priorityAvailable = available + prioritySize[subview.view.priority, default: 0] + // The number of `View`s with this priority value remaining as a `CGFloat`. + let priorityRemaining = CGFloat(priorityCount[subview.view.priority, default: 1]) + // Propose the full `crossAxis`, but only the remaining `mainAxis`. + // Divvy up the available space between each remaining `View` with this priority value. + var divviedSize = proposal + divviedSize[keyPath: Self.mainAxis] = priorityAvailable / priorityRemaining + let idealSize = subview.view.sizeThatFits(.init(divviedSize)) + cache.idealSizes[subview.index] = idealSize + size[keyPath: Self.mainAxis] += idealSize[keyPath: Self.mainAxis] + subview.spacing + // Remove our `idealSize` from the `available` space. + available -= idealSize[keyPath: Self.mainAxis] + // Decrement the number of `View`s left with this priority so space can be evenly divided + // between the remaining `View`s. + priorityCount[subview.view.priority, default: 1] -= 1 + } + return size + } + + func spacing(subviews: Subviews, cache: inout Cache) -> ViewSpacing { + subviews.reduce(into: .zero) { $0.formUnion($1.spacing) } + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + // The current progress along the `mainAxis`. + var position = CGFloat.zero + // The offset of the `_alignment` in the `maxSubview`, + // used as the reference point for alignments along this axis. + let alignmentOffset = cache.maxSubview?[alignment: _alignment, in: Self.orientation] ?? .zero + for (index, view) in subviews.enumerated() { + // Add a gap for the spacing distance from the previous subview to this one. + let spacing: CGFloat + if subviews.indices.contains(index - 1) { + if let overrideSpacing = self.spacing { + spacing = overrideSpacing + } else { + spacing = subviews[index - 1].spacing.distance(to: view.spacing, along: Self.orientation) + } + } else { + spacing = .zero + } + position += spacing + + let proposal = ProposedViewSize(cache.idealSizes[index]) + let size = view.dimensions(in: proposal) + + // Offset the placement along the `crossAxis` to align with the + // `alignment` of the `maxSubview`. + var placement = CGSize(width: bounds.minX, height: bounds.minY) + placement[keyPath: Self.mainAxis] += position + placement[keyPath: Self.crossAxis] += alignmentOffset + - size[alignment: _alignment, in: Self.orientation] + + view.place( + at: .init( + x: placement.width, + y: placement.height + ), + proposal: proposal + ) + // Move further along the stack's `mainAxis`. + position += size.size[keyPath: Self.mainAxis] + } + } +} + +@_spi(TokamakCore) +extension VStack: StackLayout { + public static var orientation: Axis { .vertical } + public var _alignment: Alignment { .init(horizontal: alignment, vertical: .center) } +} + +public extension VStack where Content == EmptyView { + init( + alignment: HorizontalAlignment = .center, + spacing: CGFloat? = nil + ) { + self.alignment = alignment + self.spacing = spacing + content = EmptyView() + } +} + +@_spi(TokamakCore) +extension HStack: StackLayout { + public static var orientation: Axis { .horizontal } + public var _alignment: Alignment { .init(horizontal: .center, vertical: alignment) } +} + +public extension HStack where Content == EmptyView { + init( + alignment: VerticalAlignment = .center, + spacing: CGFloat? = nil + ) { + self.alignment = alignment + self.spacing = spacing + content = EmptyView() + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift deleted file mode 100644 index 4d56ef738..000000000 --- a/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright 2022 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 5/24/22. -// - -import Foundation - -/// A `LayoutComputer` that aligns `Views` along a specified `Axis` -/// with a given spacing and alignment. -/// -/// The specified main `Axis` will fit to the combined width/height (depending on the axis) -/// of the children. -/// The cross axis will fit to the child with the largest height/width. -struct StackLayoutComputer: LayoutComputer { - let proposedSize: CGSize - let axis: Axis - let alignment: Alignment - let spacing: CGFloat - - init(proposedSize: CGSize, axis: Axis, alignment: Alignment, spacing: CGFloat) { - self.proposedSize = proposedSize - self.axis = axis - self.alignment = alignment - self.spacing = spacing - } - - func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - let used = context.children.reduce(CGSize.zero) { - .init( - width: $0.width + $1.dimensions.width, - height: $0.height + $1.dimensions.height - ) - } - switch axis { - case .horizontal: - return .init( - width: proposedSize.width - used.width, - height: proposedSize.height - ) - case .vertical: - return .init( - width: proposedSize.width, - height: proposedSize.height - used.height - ) - } - } - - func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - let (maxSize, fitSize) = context.children - .enumerated() - .reduce((CGSize.zero, CGSize.zero)) { res, next in - ( - .init( - width: max(res.0.width, next.element.dimensions.width), - height: max(res.0.height, next.element.dimensions.height) - ), - next.offset < child.index ? .init( - width: res.1.width + next.element.dimensions.width, - height: res.1.height + next.element.dimensions.height - ) : res.1 - ) - } - let maxDimensions = ViewDimensions(size: maxSize, alignmentGuides: [:]) - /// The gaps up to this point. - let fitSpacing = CGFloat(child.index) * spacing - switch axis { - case .horizontal: - return .init( - x: fitSize.width + fitSpacing, - y: maxDimensions[alignment.vertical] - child.dimensions[alignment.vertical] - ) - case .vertical: - return .init( - x: maxDimensions[alignment.horizontal] - child.dimensions[alignment.horizontal], - y: fitSize.height + fitSpacing - ) - } - } - - func requestSize(in context: LayoutContext) -> CGSize { - let maxDimensions = CGSize( - width: context.children - .max(by: { $0.dimensions.width < $1.dimensions.width })?.dimensions.width ?? .zero, - height: context.children - .max(by: { $0.dimensions.height < $1.dimensions.height })?.dimensions.height ?? .zero - ) - let fitDimensions = context.children - .reduce(CGSize.zero) { - .init(width: $0.width + $1.dimensions.width, height: $0.height + $1.dimensions.height) - } - - /// The combined gap size. - let fitSpacing = CGFloat(context.children.count - 1) * spacing - - switch axis { - case .horizontal: - return .init( - width: fitDimensions.width + fitSpacing, - height: maxDimensions.height - ) - case .vertical: - return .init( - width: maxDimensions.width, - height: fitDimensions.height + fitSpacing - ) - } - } -} - -public extension VStack { - static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init( - inputs: inputs, - layoutComputer: { proposedSize in - StackLayoutComputer( - proposedSize: proposedSize, - axis: .vertical, - alignment: .init(horizontal: inputs.content.alignment, vertical: .center), - spacing: inputs.content.spacing - ) - } - ) - } -} - -public extension HStack { - static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init( - inputs: inputs, - layoutComputer: { proposedSize in - StackLayoutComputer( - proposedSize: proposedSize, - axis: .horizontal, - alignment: .init(horizontal: .center, vertical: inputs.content.alignment), - spacing: inputs.content.spacing - ) - } - ) - } -} diff --git a/Sources/TokamakCore/Fiber/Layout/TextLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/TextLayoutComputer.swift deleted file mode 100644 index 2b3644a83..000000000 --- a/Sources/TokamakCore/Fiber/Layout/TextLayoutComputer.swift +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2022 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 5/24/22. -// - -import Foundation - -/// Measures the bounds of the `Text` with modifiers and wraps it inside the `proposedSize`. -struct TextLayoutComputer: LayoutComputer { - let text: Text - let proposedSize: CGSize - let environment: EnvironmentValues - - init(text: Text, proposedSize: CGSize, environment: EnvironmentValues) { - self.text = text - self.proposedSize = proposedSize - self.environment = environment - } - - func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - fatalError("Text views cannot have children.") - } - - func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - fatalError("Text views cannot have children.") - } - - func requestSize(in context: LayoutContext) -> CGSize { - environment.measureText(text, proposedSize, environment) - } -} - -public extension Text { - static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init( - inputs: inputs, - layoutComputer: { proposedSize in - TextLayoutComputer( - text: inputs.content, - proposedSize: proposedSize, - environment: inputs.environment.environment - ) - } - ) - } -} diff --git a/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift new file mode 100644 index 000000000..527d12c6a --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift @@ -0,0 +1,105 @@ +// Copyright 2022 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 6/20/22. +// + +import Foundation + +/// The preferred spacing around a `View`. +/// +/// When computing spacing in a custom `Layout`, use `distance(to:along:)` +/// to find the smallest spacing needed to accommodate the preferences +/// of the `View`s you are aligning. +public struct ViewSpacing { + /// The `View` type this `ViewSpacing` is for. + /// Some `View`s prefer different spacing based on the `View` they are adjacent to. + @_spi(TokamakCore) + public var viewType: Any.Type? + + private var top: (ViewSpacing) -> CGFloat + private var leading: (ViewSpacing) -> CGFloat + private var bottom: (ViewSpacing) -> CGFloat + private var trailing: (ViewSpacing) -> CGFloat + + public static let zero: ViewSpacing = .init( + viewType: nil, + top: { _ in 0 }, + leading: { _ in 0 }, + bottom: { _ in 0 }, + trailing: { _ in 0 } + ) + + /// Create a `ViewSpacing` instance with default values. + public init() { + self.init(viewType: nil) + } + + @_spi(TokamakCore) + public static let defaultValue: CGFloat = 8 + + @_spi(TokamakCore) + public init( + viewType: Any.Type?, + top: @escaping (ViewSpacing) -> CGFloat = { _ in Self.defaultValue }, + leading: @escaping (ViewSpacing) -> CGFloat = { _ in Self.defaultValue }, + bottom: @escaping (ViewSpacing) -> CGFloat = { _ in Self.defaultValue }, + trailing: @escaping (ViewSpacing) -> CGFloat = { _ in Self.defaultValue } + ) { + self.viewType = viewType + self.top = top + self.leading = leading + self.bottom = bottom + self.trailing = trailing + } + + public mutating func formUnion(_ other: ViewSpacing, edges: Edge.Set = .all) { + if viewType != other.viewType { + viewType = nil + } + if edges.contains(.top) { + let current = top + top = { max(current($0), other.top($0)) } + } + if edges.contains(.leading) { + let current = leading + leading = { max(current($0), other.leading($0)) } + } + if edges.contains(.bottom) { + let current = bottom + bottom = { max(current($0), other.bottom($0)) } + } + if edges.contains(.trailing) { + let current = trailing + trailing = { max(current($0), other.trailing($0)) } + } + } + + public func union(_ other: ViewSpacing, edges: Edge.Set = .all) -> ViewSpacing { + var spacing = self + spacing.formUnion(other, edges: edges) + return spacing + } + + /// The smallest spacing that accommodates the preferences of `self` and `next`. + public func distance(to next: ViewSpacing, along axis: Axis) -> CGFloat { + // Assume `next` comes after `self` either horizontally or vertically. + switch axis { + case .horizontal: + return max(trailing(next), next.leading(self)) + case .vertical: + return max(bottom(next), next.top(self)) + } + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift new file mode 100644 index 000000000..f7b06ae51 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift @@ -0,0 +1,139 @@ +// Copyright 2022 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 6/20/22. +// + +import Foundation + +/// A `Layout` container that creates a frame with constraints. +/// +/// The children are proposed the full proposal given to this container +/// clamped to the specified minimum and maximum values. +/// +/// Then the children are placed with `alignment` in the container. +private struct FlexFrameLayout: Layout { + let minWidth: CGFloat? + let idealWidth: CGFloat? + let maxWidth: CGFloat? + let minHeight: CGFloat? + let idealHeight: CGFloat? + let maxHeight: CGFloat? + let alignment: Alignment + + struct Cache { + var dimensions = [ViewDimensions]() + } + + func makeCache(subviews: Subviews) -> Cache { + .init() + } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + cache.dimensions.removeAll() + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + let bounds = CGSize( + width: min( + max(minWidth ?? .zero, proposal.width ?? idealWidth ?? .zero), + maxWidth ?? CGFloat.infinity + ), + height: min( + max(minHeight ?? .zero, proposal.height ?? idealHeight ?? .zero), + maxHeight ?? CGFloat.infinity + ) + ) + let proposal = ProposedViewSize(bounds) + + var subviewSizes = CGSize.zero + cache.dimensions = subviews.map { subview -> ViewDimensions in + let dimensions = subview.dimensions(in: proposal) + if dimensions.width > subviewSizes.width { + subviewSizes.width = dimensions.width + } + if dimensions.height > subviewSizes.height { + subviewSizes.height = dimensions.height + } + return dimensions + } + + var size = CGSize.zero + if let minWidth = minWidth, + bounds.width < subviewSizes.width + { + size.width = max(bounds.width, minWidth) + } else if let maxWidth = maxWidth, + bounds.width > subviewSizes.width + { + size.width = min(bounds.width, maxWidth) + } else { + size.width = subviewSizes.width + } + if let minHeight = minHeight, + bounds.height < subviewSizes.height + { + size.height = max(bounds.height, minHeight) + } else if let maxHeight = maxHeight, + bounds.height > subviewSizes.height + { + size.height = min(bounds.height, maxHeight) + } else { + size.height = subviewSizes.height + } + + return size + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + let proposal = ProposedViewSize(bounds.size) + let frameDimensions = ViewDimensions( + size: .init(width: bounds.width, height: bounds.height), + alignmentGuides: [:] + ) + + for (index, subview) in subviews.enumerated() { + subview.place( + at: .init( + x: bounds.minX + frameDimensions[alignment.horizontal] + - cache.dimensions[index][alignment.horizontal], + y: bounds.minY + frameDimensions[alignment.vertical] + - cache.dimensions[index][alignment.vertical] + ), + proposal: proposal + ) + } + } +} + +public extension _FlexFrameLayout { + func _visitChildren(_ visitor: V, content: Content) where V: ViewVisitor { + visitor.visit(FlexFrameLayout( + minWidth: minWidth, idealWidth: idealWidth, maxWidth: maxWidth, + minHeight: minHeight, idealHeight: idealHeight, maxHeight: maxHeight, + alignment: alignment + ).callAsFunction { + content + }) + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift new file mode 100644 index 000000000..89c8dc8e9 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift @@ -0,0 +1,103 @@ +// Copyright 2022 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 5/28/22. +// + +import Foundation + +/// A `Layout` container that requests a specific size on one or more axes. +/// +/// The container proposes the constrained size to its children, +/// then places them with `alignment` in the constrained bounds. +/// +/// Children request their own size, so they may overflow this container. +/// +/// If no fixed size is specified for a an axis, the container will use the size of its children. +private struct FrameLayout: Layout { + let width: CGFloat? + let height: CGFloat? + let alignment: Alignment + + struct Cache { + var dimensions = [ViewDimensions]() + } + + func makeCache(subviews: Subviews) -> Cache { + .init() + } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + cache.dimensions.removeAll() + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + var size = CGSize.zero + let proposal = ProposedViewSize( + width: width ?? proposal.width, + height: height ?? proposal.height + ) + cache.dimensions = subviews.map { subview -> ViewDimensions in + let dimensions = subview.dimensions(in: proposal) + if dimensions.width > size.width { + size.width = dimensions.width + } + if dimensions.height > size.height { + size.height = dimensions.height + } + return dimensions + } + return .init( + width: width ?? size.width, + height: height ?? size.height + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + let proposal = ProposedViewSize(bounds.size) + let frameDimensions = ViewDimensions( + size: .init(width: bounds.width, height: bounds.height), + alignmentGuides: [:] + ) + + for (index, subview) in subviews.enumerated() { + subview.place( + at: .init( + x: bounds.minX + frameDimensions[alignment.horizontal] + - cache.dimensions[index][alignment.horizontal], + y: bounds.minY + frameDimensions[alignment.vertical] + - cache.dimensions[index][alignment.vertical] + ), + proposal: proposal + ) + } + } +} + +public extension _FrameLayout { + func _visitChildren(_ visitor: V, content: Content) where V: ViewVisitor { + visitor.visit(FrameLayout(width: width, height: height, alignment: alignment).callAsFunction { + content + }) + } +} diff --git a/Sources/TokamakCore/Fiber/LayoutComputer.swift b/Sources/TokamakCore/Fiber/LayoutComputer.swift deleted file mode 100644 index 0eee3f398..000000000 --- a/Sources/TokamakCore/Fiber/LayoutComputer.swift +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright 2022 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 - -/// The currently computed children. -public struct LayoutContext { - public var children: [Child] - - public struct Child { - public let index: Int - public let dimensions: ViewDimensions - } -} - -/// A type that is able to propose sizes for its children. -/// -/// The order of calls is guaranteed to be: -/// 1. `proposeSize(for: child1, at: 0, in: context)` -/// 2. `proposeSize(for: child2, at: 1, in: context)` -/// 3. `position(child1)` -/// 4. `position(child2)` -/// -/// The `context` will contain all of the previously computed children from `proposeSize` calls. -/// -/// The same `LayoutComputer` instance will be used for any given view during a single layout pass. -/// -/// Sizes from `proposeSize` will be clamped, so it is safe to return negative numbers. -public 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, in context: LayoutContext) -> CGSize - - /// The child responds with their size and we place them relative to our origin. - func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint - - /// Request a size for ourself from our parent. - func requestSize(in context: LayoutContext) -> CGSize -} diff --git a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift new file mode 100644 index 000000000..9111d7340 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -0,0 +1,136 @@ +// Copyright 2022 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 6/16/22. +// + +import Foundation + +extension FiberReconciler { + final class Caches { + var elementIndices = [ObjectIdentifier: Int]() + var layoutCaches = [ObjectIdentifier: LayoutCache]() + var layoutSubviews = [ObjectIdentifier: LayoutSubviews]() + var mutations = [Mutation]() + + struct LayoutCache { + /// The erased `Layout.Cache` value. + var cache: AnyLayout.Cache + + /// Cached values for `sizeThatFits` calls. + var sizeThatFits: [SizeThatFitsRequest: CGSize] + + /// Cached values for `dimensions(in:)` calls. + var dimensions: [SizeThatFitsRequest: ViewDimensions] + + /// Does this cache need to be updated before using? + /// Set to `true` whenever the subviews or the container changes. + var isDirty: Bool + + /// Empty the cached values and flag the cache as dirty. + mutating func markDirty() { + isDirty = true + sizeThatFits.removeAll() + dimensions.removeAll() + } + + struct SizeThatFitsRequest: Hashable { + let proposal: ProposedViewSize + + @inlinable + init(_ proposal: ProposedViewSize) { + self.proposal = proposal + } + + func hash(into hasher: inout Hasher) { + hasher.combine(proposal.width) + hasher.combine(proposal.height) + } + } + } + + func clear() { + elementIndices = [:] + layoutSubviews = [:] + mutations = [] + } + + func layoutCache(for fiber: Fiber) -> LayoutCache? { + guard let layout = fiber.layout else { return nil } + return layoutCaches[ + ObjectIdentifier(fiber), + default: .init( + cache: layout.makeCache(subviews: layoutSubviews(for: fiber)), + sizeThatFits: [:], + dimensions: [:], + isDirty: false + ) + ] + } + + func updateLayoutCache(for fiber: Fiber, _ action: (inout LayoutCache) -> R) -> R? { + guard let layout = fiber.layout else { return nil } + let subviews = layoutSubviews(for: fiber) + let key = ObjectIdentifier(fiber) + var cache = layoutCaches[ + key, + default: .init( + cache: layout.makeCache(subviews: subviews), + sizeThatFits: [:], + dimensions: [:], + isDirty: false + ) + ] + // If the cache is dirty, update it before calling `action`. + if cache.isDirty { + layout.updateCache(&cache.cache, subviews: subviews) + cache.isDirty = false + } + defer { layoutCaches[key] = cache } + return action(&cache) + } + + func layoutSubviews(for fiber: Fiber) -> LayoutSubviews { + layoutSubviews[ObjectIdentifier(fiber), default: .init(fiber)] + } + + func elementIndex(for fiber: Fiber, increment: Bool = false) -> Int { + let key = ObjectIdentifier(fiber) + let result = elementIndices[key, default: 0] + if increment { + elementIndices[key] = result + 1 + } + return result + } + } +} + +protocol FiberReconcilerPass { + /// Run this pass with the given inputs. + /// + /// - Parameter reconciler: The `FiberReconciler` running this pass. + /// - Parameter root: The node to start the pass from. + /// The top of the `View` hierarchy when `useDynamicLayout` is enabled. + /// Otherwise, the same as `reconcileRoot`. + /// - Parameter reconcileRoot: The topmost node that needs reconciliation. + /// When `useDynamicLayout` is enabled, this can be used to limit + /// the number of operations performed during reconciliation. + /// - Parameter caches: The shared cache data for this and other passes. + func run( + in reconciler: FiberReconciler, + root: FiberReconciler.TreeReducer.Result, + reconcileRoot: FiberReconciler.Fiber, + caches: FiberReconciler.Caches + ) +} diff --git a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift new file mode 100644 index 000000000..7ef73d31a --- /dev/null +++ b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift @@ -0,0 +1,69 @@ +// Copyright 2022 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 6/16/22. +// + +import Foundation + +// Layout from the top down. +struct LayoutPass: FiberReconcilerPass { + func run( + in reconciler: FiberReconciler, + root: FiberReconciler.TreeReducer.Result, + reconcileRoot: FiberReconciler.Fiber, + caches: FiberReconciler.Caches + ) where R: FiberRenderer { + guard let root = root.fiber else { return } + var fiber = root + + while true { + // Place subviews for each element fiber as we walk the tree. + if fiber.element != nil { + caches.updateLayoutCache(for: fiber) { cache in + fiber.layout?.placeSubviews( + in: .init( + origin: .zero, + size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + proposal: fiber.geometry?.proposal ?? .unspecified, + subviews: caches.layoutSubviews(for: fiber), + cache: &cache.cache + ) + } + } + + if let child = fiber.child { + // Continue down the tree. + fiber = child + continue + } + + while fiber.sibling == nil { + // Exit at the top of the `View` tree + guard let parent = fiber.parent else { return } + guard parent !== root else { return } + // Walk up to the next parent. + fiber = parent + } + + // Walk across to the next sibling. + fiber = fiber.sibling! + } + } +} + +extension FiberReconcilerPass where Self == LayoutPass { + static var layout: LayoutPass { .init() } +} diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift new file mode 100644 index 000000000..cde3bbf0d --- /dev/null +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -0,0 +1,286 @@ +// Copyright 2022 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 6/16/22. +// + +import Foundation + +/// 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│ +/// └───────┴────┘ +/// ``` +struct ReconcilePass: FiberReconcilerPass { + func run( + in reconciler: FiberReconciler, + root: FiberReconciler.TreeReducer.Result, + reconcileRoot: FiberReconciler.Fiber, + caches: FiberReconciler.Caches + ) where R: FiberRenderer { + var node = root + + // Enabled when we reach the `reconcileRoot`. + var shouldReconcile = false + + // Traits that should be attached to the nearest rendered child. + var pendingTraits = _ViewTraitStore() + + while true { + if node.fiber === reconcileRoot || node.fiber?.alternate === reconcileRoot { + shouldReconcile = true + } + + // If this fiber has an element, set its `elementIndex` + // and increment the `elementIndices` value for its `elementParent`. + if node.fiber?.element != nil, + let elementParent = node.fiber?.elementParent + { + node.fiber?.elementIndex = caches.elementIndex(for: elementParent, increment: true) + } + + // Perform work on the node. + if shouldReconcile, + let mutation = reconcile(node, in: reconciler, caches: caches) + { + caches.mutations.append(mutation) + } + + // Pass view traits down to the nearest element fiber. + if let traits = node.fiber?.outputs.traits, + !traits.values.isEmpty + { + if node.fiber?.element == nil { + pendingTraits = traits + } + } + // Clear the pending traits once they have been applied to the target. + if node.fiber?.element != nil && !pendingTraits.values.isEmpty { + pendingTraits = .init() + } + + // Ensure the `TreeReducer` can access any necessary state. + node.elementIndices = caches.elementIndices + node.pendingTraits = pendingTraits + + // Compute the children of the node. + let reducer = FiberReconciler.TreeReducer.SceneVisitor(initialResult: node) + node.visitChildren(reducer) + + if reconciler.renderer.useDynamicLayout, + let fiber = node.fiber + { + if let element = fiber.element, + let elementParent = fiber.elementParent + { + let parentKey = ObjectIdentifier(elementParent) + let subview = LayoutSubview( + id: ObjectIdentifier(fiber), + traits: node.fiber?.outputs.traits, + fiber: fiber, + element: element, + caches: caches + ) + caches.layoutSubviews[parentKey, default: .init(elementParent)].storage.append(subview) + } + } + + // Setup the alternate if it doesn't exist yet. + if node.fiber?.alternate == nil { + _ = node.fiber?.createAndBindAlternate?() + } + + // Walk 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 { + // The alternate has a child that no longer exists. + if let parent = node.fiber?.element != nil ? node.fiber : node.fiber?.elementParent { + invalidateCache(for: parent, in: reconciler, caches: caches) + } + walk(alternateChild) { node in + if let element = node.element, + let parent = node.elementParent?.element + { + // Removals must happen in reverse order, so a child element + // is removed before its parent. + caches.mutations.insert(.remove(element: element, parent: parent), at: 0) + } + return true + } + } + if reducer.result.child == nil { + // Make sure we clear the child if there was none + node.fiber?.child = nil + node.fiber?.alternate?.child = nil + } + + // If we've made it back to the root, then exit. + if node === root { + return + } + + // Now walk back up the tree until we find a sibling. + while node.sibling == nil { + if let fiber = node.fiber, + fiber.element != nil + { + propagateCacheInvalidation(for: fiber, in: reconciler, caches: caches) + } + + var alternateSibling = node.fiber?.alternate?.sibling + // The alternate had siblings that no longer exist. + while alternateSibling != nil { + if let fiber = alternateSibling?.elementParent { + invalidateCache(for: fiber, in: reconciler, caches: caches) + } + if let element = alternateSibling?.element, + let parent = alternateSibling?.elementParent?.element + { + // Removals happen in reverse order, so a child element is removed before + // its parent. + caches.mutations.insert(.remove(element: element, parent: parent), at: 0) + } + alternateSibling = alternateSibling?.sibling + } + guard let parent = node.parent else { return } + // When we walk back to the root, exit + guard parent !== root.fiber?.alternate else { return } + node = parent + } + + if let fiber = node.fiber { + propagateCacheInvalidation(for: fiber, in: reconciler, caches: caches) + } + + // Walk across to the sibling, and repeat. + node = node.sibling! + } + } + + /// Compare `node` with its alternate, and add any mutations to the list. + func reconcile( + _ node: FiberReconciler.TreeReducer.Result, + in reconciler: FiberReconciler, + caches: FiberReconciler.Caches + ) -> Mutation? { + if let element = node.fiber?.element, + let index = node.fiber?.elementIndex, + let parent = node.fiber?.elementParent?.element + { + if node.fiber?.alternate == nil { // This didn't exist before (no alternate) + if let fiber = node.fiber?.elementParent { + invalidateCache(for: fiber, in: reconciler, caches: caches) + } + return .insert(element: element, parent: parent, index: index) + } else if node.fiber?.typeInfo?.type != node.fiber?.alternate?.typeInfo?.type, + let previous = node.fiber?.alternate?.element + { + if let fiber = node.fiber?.elementParent { + invalidateCache(for: fiber, in: reconciler, caches: caches) + } + // This is a completely different type of view. + return .replace(parent: parent, previous: previous, replacement: element) + } else if let newContent = node.newContent, + newContent != element.content + { + if let fiber = node.fiber?.elementParent { + invalidateCache(for: fiber, in: reconciler, caches: caches) + } + // This is the same type of view, but its backing data has changed. + return .update( + previous: element, + newContent: newContent, + geometry: node.fiber?.geometry ?? .init( + origin: .init(origin: .zero), + dimensions: .init(size: .zero, alignmentGuides: [:]), + proposal: .unspecified + ) + ) + } + } + return nil + } + + /// Remove cached size values if something changed. + func invalidateCache( + for fiber: FiberReconciler.Fiber, + in reconciler: FiberReconciler, + caches: FiberReconciler.Caches + ) { + guard reconciler.renderer.useDynamicLayout else { return } + caches.updateLayoutCache(for: fiber) { cache in + cache.markDirty() + } + if let alternate = fiber.alternate { + caches.updateLayoutCache(for: alternate) { cache in + cache.markDirty() + } + } + } + + @inlinable + func propagateCacheInvalidation( + for fiber: FiberReconciler.Fiber, + in reconciler: FiberReconciler, + caches: FiberReconciler.Caches + ) { + guard caches.layoutCache(for: fiber)?.isDirty ?? false, + let elementParent = fiber.elementParent + else { return } + invalidateCache(for: elementParent, in: reconciler, caches: caches) + } +} + +extension FiberReconcilerPass where Self == ReconcilePass { + static var reconcile: ReconcilePass { ReconcilePass() } +} diff --git a/Sources/TokamakCore/Fiber/Scene/SceneArguments.swift b/Sources/TokamakCore/Fiber/Scene/SceneArguments.swift index fdb943c7e..061f61c4c 100644 --- a/Sources/TokamakCore/Fiber/Scene/SceneArguments.swift +++ b/Sources/TokamakCore/Fiber/Scene/SceneArguments.swift @@ -20,9 +20,6 @@ import Foundation public extension Scene { // By default, we simply pass the inputs through without modifications. static func _makeScene(_ inputs: SceneInputs) -> SceneOutputs { - .init( - inputs: inputs, - layoutComputer: RootLayoutComputer.init - ) + .init(inputs: inputs) } } diff --git a/Sources/TokamakCore/Fiber/ViewArguments.swift b/Sources/TokamakCore/Fiber/ViewArguments.swift index 8cc66ee07..332c37bdc 100644 --- a/Sources/TokamakCore/Fiber/ViewArguments.swift +++ b/Sources/TokamakCore/Fiber/ViewArguments.swift @@ -20,8 +20,11 @@ import Foundation /// Data passed to `_makeView` to create the `ViewOutputs` used in reconciling/rendering. public struct ViewInputs { public let content: V + @_spi(TokamakCore) public let environment: EnvironmentBox + + public let traits: _ViewTraitStore? } /// Data used to reconcile and render a `View` and its children. @@ -29,10 +32,10 @@ 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 - let makeLayoutComputer: (CGSize) -> LayoutComputer - /// The `LayoutComputer` used to propose sizes for the children of this view. - var layoutComputer: LayoutComputer! + + let traits: _ViewTraitStore? } @_spi(TokamakCore) @@ -49,15 +52,13 @@ public extension ViewOutputs { inputs: ViewInputs, environment: EnvironmentValues? = nil, preferences: _PreferenceStore? = nil, - layoutComputer: ((CGSize) -> LayoutComputer)? = nil + traits: _ViewTraitStore? = 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() - makeLayoutComputer = layoutComputer ?? { proposedSize in - ShrinkWrapLayoutComputer(proposedSize: proposedSize) - } + self.traits = traits ?? inputs.traits } } @@ -71,7 +72,11 @@ public extension View { public extension ModifiedContent where Content: View, Modifier: ViewModifier { static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - Modifier._makeView(.init(content: inputs.content.modifier, environment: inputs.environment)) + Modifier._makeView(.init( + content: inputs.content.modifier, + environment: inputs.environment, + traits: inputs.traits + )) } func _visitChildren(_ visitor: V) where V: ViewVisitor { diff --git a/Sources/TokamakCore/Fiber/ViewGeometry.swift b/Sources/TokamakCore/Fiber/ViewGeometry.swift index 0a0adf8bb..845aa30b8 100644 --- a/Sources/TokamakCore/Fiber/ViewGeometry.swift +++ b/Sources/TokamakCore/Fiber/ViewGeometry.swift @@ -23,6 +23,8 @@ public struct ViewGeometry: Equatable { @_spi(TokamakCore) public let dimensions: ViewDimensions + + let proposal: ProposedViewSize } /// The position of the `View` relative to its parent. diff --git a/Sources/TokamakCore/Modifiers/StyleModifiers.swift b/Sources/TokamakCore/Modifiers/StyleModifiers.swift index 46433bec4..4da9296e4 100644 --- a/Sources/TokamakCore/Modifiers/StyleModifiers.swift +++ b/Sources/TokamakCore/Modifiers/StyleModifiers.swift @@ -33,9 +33,8 @@ public struct _BackgroundLayout: _PrimitiveView } public func _visitChildren(_ visitor: V) where V: ViewVisitor { - // Visit the content first so it can request its size before laying out the background - visitor.visit(content) visitor.visit(background) + visitor.visit(content) } } @@ -143,6 +142,11 @@ public struct _OverlayLayout: _PrimitiveView public let content: Content public let overlay: Overlay public let alignment: Alignment + + public func _visitChildren(_ visitor: V) where V: ViewVisitor { + visitor.visit(content) + visitor.visit(overlay) + } } public struct _OverlayModifier: ViewModifier, EnvironmentReader diff --git a/Sources/TokamakCore/Shapes/AnyShape.swift b/Sources/TokamakCore/Shapes/AnyShape.swift index 411d83aeb..45b6cbb4a 100644 --- a/Sources/TokamakCore/Shapes/AnyShape.swift +++ b/Sources/TokamakCore/Shapes/AnyShape.swift @@ -14,7 +14,7 @@ import Foundation -internal protocol AnyShapeBox { +protocol AnyShapeBox { var animatableDataBox: _AnyAnimatableData { get set } func path(in rect: CGRect) -> Path @@ -49,7 +49,7 @@ private struct ConcreteAnyShapeBox: AnyShapeBox { } public struct AnyShape: Shape { - internal var box: AnyShapeBox + var box: AnyShapeBox private init(_ box: AnyShapeBox) { self.box = box diff --git a/Sources/TokamakCore/Shapes/Shape.swift b/Sources/TokamakCore/Shapes/Shape.swift index a9ef474fe..fb4649edc 100644 --- a/Sources/TokamakCore/Shapes/Shape.swift +++ b/Sources/TokamakCore/Shapes/Shape.swift @@ -31,10 +31,7 @@ public extension Shape { // SwiftUI seems to not compute the path at all and just return // the following. - CGSize( - width: proposal.width ?? 10, - height: proposal.height ?? 10 - ) + proposal.replacingUnspecifiedDimensions() } } @@ -69,7 +66,9 @@ public struct FillStyle: Equatable { } } -public struct _ShapeView: _PrimitiveView where Content: Shape, Style: ShapeStyle { +public struct _ShapeView: _PrimitiveView, Layout where Content: Shape, + Style: ShapeStyle +{ @Environment(\.self) public var environment @@ -86,8 +85,30 @@ public struct _ShapeView: _PrimitiveView where Content: Shape, S self.fillStyle = fillStyle } - public static func _makeView(_ inputs: ViewInputs<_ShapeView>) -> ViewOutputs { - .init(inputs: inputs, layoutComputer: FlexLayoutComputer.init) + public func spacing(subviews: Subviews, cache: inout ()) -> ViewSpacing { + .init() + } + + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + proposal.replacingUnspecifiedDimensions() + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place( + at: bounds.origin, + proposal: .init(width: bounds.width, height: bounds.height) + ) + } } } diff --git a/Sources/TokamakCore/Tokens/LayoutDirection.swift b/Sources/TokamakCore/Tokens/LayoutDirection.swift new file mode 100644 index 000000000..dd1017be6 --- /dev/null +++ b/Sources/TokamakCore/Tokens/LayoutDirection.swift @@ -0,0 +1,32 @@ +// Copyright 2022 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 6/20/22. +// + +public enum LayoutDirection: Hashable, CaseIterable { + case leftToRight + case rightToLeft +} + +extension EnvironmentValues { + private enum LayoutDirectionKey: EnvironmentKey { + static var defaultValue: LayoutDirection = .leftToRight + } + + public var layoutDirection: LayoutDirection { + get { self[LayoutDirectionKey.self] } + set { self[LayoutDirectionKey.self] = newValue } + } +} diff --git a/Sources/TokamakCore/ViewTraits/_ViewTraitKey.swift b/Sources/TokamakCore/ViewTraits/_ViewTraitKey.swift index 64c3a82dc..a49a3d84b 100644 --- a/Sources/TokamakCore/ViewTraits/_ViewTraitKey.swift +++ b/Sources/TokamakCore/ViewTraits/_ViewTraitKey.swift @@ -41,6 +41,14 @@ public struct _TraitWritingModifier: ViewModifier, _TraitWritingModifierP public func modifyViewTraitStore(_ viewTraitStore: inout _ViewTraitStore) { viewTraitStore.insert(value, forKey: Trait.self) } + + public static func _makeView(_ inputs: ViewInputs<_TraitWritingModifier>) + -> ViewOutputs + { + var store = inputs.traits ?? .init() + store.insert(inputs.content.value, forKey: Trait.self) + return .init(inputs: inputs, traits: store) + } } extension ModifiedContent: _TraitWritingModifierProtocol diff --git a/Sources/TokamakCore/ViewTraits/_ViewTraitStore.swift b/Sources/TokamakCore/ViewTraits/_ViewTraitStore.swift index 12cb8deb6..3dcb468ad 100644 --- a/Sources/TokamakCore/ViewTraits/_ViewTraitStore.swift +++ b/Sources/TokamakCore/ViewTraits/_ViewTraitStore.swift @@ -16,21 +16,21 @@ // public struct _ViewTraitStore { - public var values = [String: Any]() + public var values = [ObjectIdentifier: Any]() - public init(values: [String: Any] = [:]) { + public init(values: [ObjectIdentifier: Any] = [:]) { self.values = values } public func value(forKey key: Key.Type = Key.self) -> Key.Value where Key: _ViewTraitKey { - values[String(reflecting: key)] as? Key.Value ?? Key.defaultValue + values[ObjectIdentifier(key)] as? Key.Value ?? Key.defaultValue } public mutating func insert(_ value: Key.Value, forKey key: Key.Type = Key.self) where Key: _ViewTraitKey { - values[String(reflecting: key)] = value + values[ObjectIdentifier(key)] = value } } diff --git a/Sources/TokamakCore/Views/Layout/HStack.swift b/Sources/TokamakCore/Views/Layout/HStack.swift index 5c335c476..2d9ad1f37 100644 --- a/Sources/TokamakCore/Views/Layout/HStack.swift +++ b/Sources/TokamakCore/Views/Layout/HStack.swift @@ -27,7 +27,10 @@ public let defaultStackSpacing: CGFloat = 8 /// } public struct HStack: View where Content: View { public let alignment: VerticalAlignment - let spacing: CGFloat + + @_spi(TokamakCore) + public let spacing: CGFloat? + public let content: Content public init( @@ -36,7 +39,7 @@ public struct HStack: View where Content: View { @ViewBuilder content: () -> Content ) { self.alignment = alignment - self.spacing = spacing ?? defaultStackSpacing + self.spacing = spacing self.content = content() } @@ -61,5 +64,5 @@ public struct _HStackProxy where Content: View { public init(_ subject: HStack) { self.subject = subject } - public var spacing: CGFloat { subject.spacing } + public var spacing: CGFloat { subject.spacing ?? defaultStackSpacing } } diff --git a/Sources/TokamakCore/Views/Layout/VStack.swift b/Sources/TokamakCore/Views/Layout/VStack.swift index 8efd49a9b..e190480f3 100644 --- a/Sources/TokamakCore/Views/Layout/VStack.swift +++ b/Sources/TokamakCore/Views/Layout/VStack.swift @@ -22,7 +22,10 @@ import Foundation /// } public struct VStack: View where Content: View { public let alignment: HorizontalAlignment - let spacing: CGFloat + + @_spi(TokamakCore) + public let spacing: CGFloat? + public let content: Content public init( @@ -31,7 +34,7 @@ public struct VStack: View where Content: View { @ViewBuilder content: () -> Content ) { self.alignment = alignment - self.spacing = spacing ?? defaultStackSpacing + self.spacing = spacing self.content = content() } @@ -56,5 +59,5 @@ public struct _VStackProxy where Content: View { public init(_ subject: VStack) { self.subject = subject } - public var spacing: CGFloat { subject.spacing } + public var spacing: CGFloat { subject.spacing ?? defaultStackSpacing } } diff --git a/Sources/TokamakCore/Views/Text/Text.swift b/Sources/TokamakCore/Views/Text/Text.swift index 349f1f895..a32bb2890 100644 --- a/Sources/TokamakCore/Views/Text/Text.swift +++ b/Sources/TokamakCore/Views/Text/Text.swift @@ -195,3 +195,24 @@ public extension Text { ])) } } + +extension Text: Layout { + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + environment.measureText(self, proposal, environment) + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place(at: bounds.origin, proposal: proposal) + } + } +} diff --git a/Sources/TokamakCoreBenchmark/main.swift b/Sources/TokamakCoreBenchmark/main.swift index 351a476bc..cac2cc04d 100644 --- a/Sources/TokamakCoreBenchmark/main.swift +++ b/Sources/TokamakCoreBenchmark/main.swift @@ -66,9 +66,10 @@ benchmark("update wide (FiberReconciler)") { state in let reconciler = TestFiberRenderer( .root, size: .init(width: 500, height: 500), - useDynamicLayout: false + useDynamicLayout: true ).render(view) guard case let .view(view, _) = reconciler.current // RootView + .child? // LayoutView .child? // ModifiedContent .child? // _ViewModifier_Content .child? // UpdateLast @@ -127,9 +128,10 @@ benchmark("update narrow (FiberReconciler)") { state in let reconciler = TestFiberRenderer( .root, size: .init(width: 500, height: 500), - useDynamicLayout: false + useDynamicLayout: true ).render(view) guard case let .view(view, _) = reconciler.current // RootView + .child? // LayoutView .child? // ModifiedContent .child? // _ViewModifier_Content .child? // UpdateLast @@ -199,7 +201,7 @@ benchmark("update deep (FiberReconciler)") { state in let reconciler = TestFiberRenderer( .root, size: .init(width: 500, height: 500), - useDynamicLayout: false + useDynamicLayout: true ).render(view) guard case let .view(view, _) = reconciler.current // RootView .child? // ModifiedContent @@ -242,7 +244,7 @@ struct UpdateShallow: View { var body: some View { VStack { Text(update) - RecursiveView(500) + RecursiveView(1000) Button("Update") { update = "B" } @@ -269,7 +271,7 @@ benchmark("update shallow (FiberReconciler)") { _ in let reconciler = TestFiberRenderer( .root, size: .init(width: 500, height: 500), - useDynamicLayout: false + useDynamicLayout: true ).render(view) guard case let .view(view, _) = reconciler.current // RootView .child? // ModifiedContent diff --git a/Sources/TokamakDOM/Core.swift b/Sources/TokamakDOM/Core.swift index b2d981899..803616c5d 100644 --- a/Sources/TokamakDOM/Core.swift +++ b/Sources/TokamakDOM/Core.swift @@ -117,6 +117,7 @@ public typealias CGAffineTransform = TokamakCore.CGAffineTransform #endif public typealias Angle = TokamakCore.Angle +public typealias Axis = TokamakCore.Axis public typealias UnitPoint = TokamakCore.UnitPoint public typealias Edge = TokamakCore.Edge @@ -179,6 +180,9 @@ public typealias View = TokamakCore.View public typealias AnyView = TokamakCore.AnyView public typealias EmptyView = TokamakCore.EmptyView +public typealias Layout = TokamakCore.Layout +public typealias AnyLayout = TokamakCore.AnyLayout + // MARK: Toolbars public typealias ToolbarItem = TokamakCore.ToolbarItem diff --git a/Sources/TokamakDOM/DOMFiberRenderer.swift b/Sources/TokamakDOM/DOMFiberRenderer.swift index bbcbd4742..6deb29cdf 100644 --- a/Sources/TokamakDOM/DOMFiberRenderer.swift +++ b/Sources/TokamakDOM/DOMFiberRenderer.swift @@ -146,12 +146,16 @@ public struct DOMFiberRenderer: FiberRenderer { public func measureText( _ text: Text, - proposedSize: CGSize, + proposal: ProposedViewSize, in environment: EnvironmentValues ) -> CGSize { let element = createElement(.init(from: .init(from: text, useDynamicLayout: true))) - _ = element.style.setProperty("maxWidth", "\(proposedSize.width)px") - _ = element.style.setProperty("maxHeight", "\(proposedSize.height)px") + if let width = proposal.width { + _ = element.style.setProperty("maxWidth", "\(width)px") + } + if let height = proposal.height { + _ = element.style.setProperty("maxHeight", "\(height)px") + } _ = document.body.appendChild(element) let rect = element.getBoundingClientRect!() let size = CGSize( diff --git a/Sources/TokamakStaticHTML/Core.swift b/Sources/TokamakStaticHTML/Core.swift index 77134a1e5..9820ebeab 100644 --- a/Sources/TokamakStaticHTML/Core.swift +++ b/Sources/TokamakStaticHTML/Core.swift @@ -61,9 +61,16 @@ public typealias AngularGradient = TokamakCore.AngularGradient // MARK: Primitive values +public typealias Axis = TokamakCore.Axis + public typealias Color = TokamakCore.Color public typealias Font = TokamakCore.Font +public typealias Alignment = TokamakCore.Alignment +public typealias AlignmentID = TokamakCore.AlignmentID +public typealias HorizontalAlignment = TokamakCore.HorizontalAlignment +public typealias VerticalAlignment = TokamakCore.VerticalAlignment + #if !canImport(CoreGraphics) public typealias CGAffineTransform = TokamakCore.CGAffineTransform #endif @@ -93,6 +100,9 @@ public typealias View = TokamakCore.View public typealias AnyView = TokamakCore.AnyView public typealias EmptyView = TokamakCore.EmptyView +public typealias Layout = TokamakCore.Layout +public typealias AnyLayout = TokamakCore.AnyLayout + // MARK: Toolbars public typealias ToolbarItem = TokamakCore.ToolbarItem diff --git a/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift b/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift index bc810d8db..84c119a19 100644 --- a/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift +++ b/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift @@ -77,9 +77,7 @@ extension _FrameLayout: DOMViewModifier { extension _FrameLayout: HTMLConvertible { public var tag: String { "div" } public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { - guard !useDynamicLayout else { return [ - "style": "overflow: hidden;", - ] } + guard !useDynamicLayout else { return [:] } return attributes } } @@ -106,6 +104,15 @@ extension _FlexFrameLayout: DOMViewModifier { } } +@_spi(TokamakStaticHTML) +extension _FlexFrameLayout: HTMLConvertible { + public var tag: String { "div" } + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + guard !useDynamicLayout else { return [:] } + return attributes + } +} + private extension Edge { var cssValue: String { switch self { @@ -221,32 +228,26 @@ extension _BackgroundLayout: HTMLConvertible { } public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V: ViewVisitor { - if useDynamicLayout { - return { - $0.visit(HTML("div", ["style": "z-index: 1;"]) { content }) - $0.visit(background) - } - } else { - return { - $0.visit(HTML( - "div", - ["style": """ - display: flex; - justify-content: \(alignment.horizontal.flexAlignment); - align-items: \(alignment.vertical.flexAlignment); - grid-area: a; + guard !useDynamicLayout else { return nil } + return { + $0.visit(HTML( + "div", + ["style": """ + display: flex; + justify-content: \(alignment.horizontal.flexAlignment); + align-items: \(alignment.vertical.flexAlignment); + grid-area: a; - width: 0; min-width: 100%; - height: 0; min-height: 100%; - overflow: hidden; - """] - ) { - background - }) - $0.visit(HTML("div", ["style": "grid-area: a;"]) { - content - }) - } + width: 0; min-width: 100%; + height: 0; min-height: 100%; + overflow: hidden; + """] + ) { + background + }) + $0.visit(HTML("div", ["style": "grid-area: a;"]) { + content + }) } } } @@ -280,3 +281,38 @@ extension _OverlayLayout: _HTMLPrimitive { ) } } + +@_spi(TokamakStaticHTML) +extension _OverlayLayout: HTMLConvertible { + public var tag: String { "div" } + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + guard !useDynamicLayout else { return [:] } + return ["style": "display: inline-grid; grid-template-columns: auto auto;"] + } + + public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V: ViewVisitor { + guard !useDynamicLayout else { return nil } + return { + $0.visit(HTML("div", ["style": "grid-area: a;"]) { + content + }) + $0.visit( + HTML( + "div", + ["style": """ + display: flex; + justify-content: \(alignment.horizontal.flexAlignment); + align-items: \(alignment.vertical.flexAlignment); + grid-area: a; + + width: 0; min-width: 100%; + height: 0; min-height: 100%; + overflow: hidden; + """] + ) { + overlay + } + ) + } + } +} diff --git a/Sources/TokamakStaticHTML/Shapes/Path.swift b/Sources/TokamakStaticHTML/Shapes/Path.swift index a5735535d..1938f5d94 100644 --- a/Sources/TokamakStaticHTML/Shapes/Path.swift +++ b/Sources/TokamakStaticHTML/Shapes/Path.swift @@ -51,20 +51,10 @@ extension Path: _HTMLPrimitive { "height": flexibleHeight ?? "\(max(0, rect.size.height))", "x": "\(rect.origin.x - (rect.size.width / 2))", "y": "\(rect.origin.y - (rect.size.height / 2))", - ].merging(stroke, uniquingKeysWith: uniqueKeys), - layoutComputer: { - if sizing == .flexible { - return FlexLayoutComputer(proposedSize: $0) - } else { - return FrameLayoutComputer( - proposedSize: $0, - width: max(0, rect.size.width), - height: max(0, rect.size.height), - alignment: .center - ) - } - } - ) + ].merging(stroke, uniquingKeysWith: uniqueKeys) + ) { proposal, _ in + proposal.replacingUnspecifiedDimensions() + } case let .ellipse(rect): return HTML( "ellipse", @@ -74,7 +64,9 @@ extension Path: _HTMLPrimitive { "rx": flexibleCenterX ?? "\(rect.size.width)", "ry": flexibleCenterY ?? "\(rect.size.height)"] .merging(stroke, uniquingKeysWith: uniqueKeys) - ) + ) { proposal, _ in + proposal.replacingUnspecifiedDimensions() + } case let .roundedRect(roundedRect): // When cornerRadius is nil we use 50% rx. let size = roundedRect.rect.size @@ -100,7 +92,9 @@ extension Path: _HTMLPrimitive { ] .merging(cornerRadius, uniquingKeysWith: uniqueKeys) .merging(stroke, uniquingKeysWith: uniqueKeys) - ) + ) { proposal, _ in + proposal.replacingUnspecifiedDimensions() + } case let .stroked(stroked): return stroked.path.svgBody(strokeStyle: stroked.style) case let .trimmed(trimmed): diff --git a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift index ccff8f56c..ccd9e02b2 100644 --- a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift +++ b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift @@ -150,14 +150,33 @@ extension HStack: HTMLConvertible { } } +@_spi(TokamakCore) +extension LayoutView: HTMLConvertible { + public var tag: String { "div" } + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + [:] + } +} + public struct StaticHTMLFiberRenderer: FiberRenderer { public let rootElement: HTMLElement public let defaultEnvironment: EnvironmentValues - public let sceneSize: CGSize = .zero - public let useDynamicLayout: Bool = false + public let sceneSize: CGSize + public let useDynamicLayout: Bool public init() { - rootElement = .init(tag: "body", attributes: [:], innerHTML: nil, children: []) + self.init(useDynamicLayout: false, sceneSize: .zero) + } + + /// An internal initializer used for testing dynamic layout with the `StaticHTMLFiberRenderer`. + /// Normal use cases for dynamic layout require `TokamakDOM`. + @_spi(TokamakStaticHTML) + public init(useDynamicLayout: Bool, sceneSize: CGSize) { + self.useDynamicLayout = useDynamicLayout + self.sceneSize = sceneSize + rootElement = .init( + tag: "body", attributes: ["style": "margin: 0;"], innerHTML: nil, children: [] + ) var environment = EnvironmentValues() environment[_ColorSchemeKey.self] = .light defaultEnvironment = environment @@ -188,16 +207,42 @@ public struct StaticHTMLFiberRenderer: FiberRenderer { case let .update(previous, newContent, _): previous.update(with: newContent) case let .layout(element, data): - print("Received layout message \(data) for \(element)") + element.content.attributes["style", default: ""] += """ + position: absolute; + left: \(data.origin.x)px; + top: \(data.origin.y)px; + width: \(data.dimensions.width)px; + height: \(data.dimensions.height)px; + """ } } } public func measureText( _ text: Text, - proposedSize: CGSize, + proposal: ProposedViewSize, in environment: EnvironmentValues ) -> CGSize { .zero } + + public func render(_ app: A) -> String { + _ = FiberReconciler(self, app) + return """ + + + \(rootElement.description) + + """ + } + + public func render(_ view: V) -> String { + _ = FiberReconciler(self, view) + return """ + + + \(rootElement.description) + + """ + } } diff --git a/Sources/TokamakStaticHTML/Views/HTML.swift b/Sources/TokamakStaticHTML/Views/HTML.swift index 2577681ec..974fb7120 100644 --- a/Sources/TokamakStaticHTML/Views/HTML.swift +++ b/Sources/TokamakStaticHTML/Views/HTML.swift @@ -87,12 +87,12 @@ public extension AnyHTML { } } -public struct HTML: View, AnyHTML { +public struct HTML: View, AnyHTML, Layout { public let tag: String public let namespace: String? public let attributes: [HTMLAttribute: String] + let sizeThatFits: ((ProposedViewSize, LayoutSubviews) -> CGSize)? let content: Content - let layoutComputer: (CGSize) -> LayoutComputer let visitContent: (ViewVisitor) -> () fileprivate let cachedInnerHTML: String? @@ -111,7 +111,26 @@ public struct HTML: View, AnyHTML { } public static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init(inputs: inputs, layoutComputer: inputs.content.layoutComputer) + .init(inputs: inputs) + } + + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + sizeThatFits?(proposal, subviews) ?? subviews.first?.sizeThatFits(proposal) ?? .zero + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place(at: bounds.origin, proposal: proposal) + } } } @@ -120,13 +139,14 @@ public extension HTML where Content: StringProtocol { _ tag: String, namespace: String? = nil, _ attributes: [HTMLAttribute: String] = [:], + sizeThatFits: ((ProposedViewSize, LayoutSubviews) -> CGSize)? = nil, content: Content ) { self.tag = tag self.namespace = namespace self.attributes = attributes + self.sizeThatFits = sizeThatFits self.content = content - layoutComputer = ShrinkWrapLayoutComputer.init cachedInnerHTML = String(content) visitContent = { _ in } } @@ -137,30 +157,14 @@ extension HTML: ParentView where Content: View { _ tag: String, namespace: String? = nil, _ attributes: [HTMLAttribute: String] = [:], + sizeThatFits: ((ProposedViewSize, LayoutSubviews) -> CGSize)? = nil, @ViewBuilder content: @escaping () -> Content ) { self.tag = tag self.namespace = namespace self.attributes = attributes + self.sizeThatFits = sizeThatFits self.content = content() - layoutComputer = ShrinkWrapLayoutComputer.init - cachedInnerHTML = nil - visitContent = { $0.visit(content()) } - } - - @_spi(TokamakCore) - public init( - _ tag: String, - namespace: String? = nil, - _ attributes: [HTMLAttribute: String] = [:], - layoutComputer: @escaping (CGSize) -> LayoutComputer, - @ViewBuilder content: @escaping () -> Content - ) { - self.tag = tag - self.namespace = namespace - self.attributes = attributes - self.content = content() - self.layoutComputer = layoutComputer cachedInnerHTML = nil visitContent = { $0.visit(content()) } } @@ -172,24 +176,13 @@ extension HTML: ParentView where Content: View { } public extension HTML where Content == EmptyView { - init( - _ tag: String, - namespace: String? = nil, - _ attributes: [HTMLAttribute: String] = [:] - ) { - self = HTML(tag, namespace: namespace, attributes) { EmptyView() } - } - - @_spi(TokamakCore) init( _ tag: String, namespace: String? = nil, _ attributes: [HTMLAttribute: String] = [:], - layoutComputer: @escaping (CGSize) -> LayoutComputer + sizeThatFits: ((ProposedViewSize, LayoutSubviews) -> CGSize)? = nil ) { - self = HTML(tag, namespace: namespace, attributes, layoutComputer: layoutComputer) { - EmptyView() - } + self = HTML(tag, namespace: namespace, attributes, sizeThatFits: sizeThatFits) { EmptyView() } } } diff --git a/Sources/TokamakTestRenderer/TestFiberRenderer.swift b/Sources/TokamakTestRenderer/TestFiberRenderer.swift index 8e201cba2..7b1eb9cd3 100644 --- a/Sources/TokamakTestRenderer/TestFiberRenderer.swift +++ b/Sources/TokamakTestRenderer/TestFiberRenderer.swift @@ -96,17 +96,25 @@ public final class TestFiberElement: FiberElement, CustomStringConvertible { } public var content: Content + public var children: [TestFiberElement] + public var geometry: ViewGeometry? public init(from content: Content) { self.content = content + children = [] } public var description: String { - "\(content.renderedValue)\(content.closingTag)" + """ + \(content.renderedValue) + \(children.map { " \($0.description)" }.joined(separator: "\n")) + \(content.closingTag) + """ } public init(renderedValue: String, closingTag: String) { content = .init(renderedValue: renderedValue, closingTag: closingTag) + children = [] } public func update(with content: Content) { @@ -122,17 +130,17 @@ public struct TestFiberRenderer: FiberRenderer { public func measureText( _ text: Text, - proposedSize: CGSize, + proposal: ProposedViewSize, in environment: EnvironmentValues ) -> CGSize { - proposedSize + proposal.replacingUnspecifiedDimensions() } public typealias ElementType = TestFiberElement public let rootElement: ElementType - public init(_ rootElement: ElementType, size: CGSize, useDynamicLayout: Bool = true) { + public init(_ rootElement: ElementType, size: CGSize, useDynamicLayout: Bool = false) { self.rootElement = rootElement sceneSize = size self.useDynamicLayout = useDynamicLayout @@ -145,8 +153,16 @@ public struct TestFiberRenderer: FiberRenderer { public func commit(_ mutations: [Mutation]) { for mutation in mutations { switch mutation { - case .insert, .remove, .replace, .layout: - break + case let .insert(element, parent, index): + parent.children.insert(element, at: index) + case let .remove(element, parent): + parent?.children.removeAll(where: { $0 === element }) + case let .replace(parent, previous, replacement): + guard let index = parent.children.firstIndex(where: { $0 === previous }) + else { continue } + parent.children[index] = replacement + case let .layout(element, geometry): + element.geometry = geometry case let .update(previous, newContent, _): previous.update(with: newContent) } diff --git a/Tests/TokamakLayoutTests/Alignment+allCases.swift b/Tests/TokamakLayoutTests/Alignment+allCases.swift new file mode 100644 index 000000000..6a96c177d --- /dev/null +++ b/Tests/TokamakLayoutTests/Alignment+allCases.swift @@ -0,0 +1,33 @@ +// Copyright 2022 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 6/20/22. +// + +#if os(macOS) +import SwiftUI +import TokamakStaticHTML + +extension SwiftUI.HorizontalAlignment { + static var allCases: [(SwiftUI.HorizontalAlignment, TokamakStaticHTML.HorizontalAlignment)] { + [(.leading, .leading), (.center, .center), (.trailing, .trailing)] + } +} + +extension SwiftUI.VerticalAlignment { + static var allCases: [(SwiftUI.VerticalAlignment, TokamakStaticHTML.VerticalAlignment)] { + [(.top, .top), (.center, .center), (.bottom, .bottom)] + } +} +#endif diff --git a/Tests/TokamakLayoutTests/ContainedZLayoutTests.swift b/Tests/TokamakLayoutTests/ContainedZLayoutTests.swift new file mode 100644 index 000000000..14638cbe1 --- /dev/null +++ b/Tests/TokamakLayoutTests/ContainedZLayoutTests.swift @@ -0,0 +1,89 @@ +// Copyright 2022 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 6/20/22. +// + +#if os(macOS) +import SwiftUI +import TokamakStaticHTML +import XCTest + +@available(macOS 12.0, *) +final class ContainedZLayoutTests: XCTestCase { + func testBackground() async { + for (nativeHorizontal, tokamakHorizontal) in SwiftUI.HorizontalAlignment.allCases { + for (nativeVertical, tokamakVertical) in SwiftUI.VerticalAlignment.allCases { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 127 / 255)) + .frame(width: 100, height: 100) + .background(alignment: .init( + horizontal: nativeHorizontal, + vertical: nativeVertical + )) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(width: 50, height: 50) + } + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 127 / 255)) + .frame(width: 100, height: 100) + .background(alignment: .init( + horizontal: tokamakHorizontal, + vertical: tokamakVertical + )) { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(width: 50, height: 50) + } + } + } + } + } + + func testOverlay() async { + for (nativeHorizontal, tokamakHorizontal) in SwiftUI.HorizontalAlignment.allCases { + for (nativeVertical, tokamakVertical) in SwiftUI.VerticalAlignment.allCases { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 127 / 255)) + .frame(width: 100, height: 100) + .overlay(alignment: .init( + horizontal: nativeHorizontal, + vertical: nativeVertical + )) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(width: 50, height: 50) + } + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 127 / 255)) + .frame(width: 100, height: 100) + .overlay(alignment: .init( + horizontal: tokamakHorizontal, + vertical: tokamakVertical + )) { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(width: 50, height: 50) + } + } + } + } + } +} +#endif diff --git a/Tests/TokamakLayoutTests/FrameTests.swift b/Tests/TokamakLayoutTests/FrameTests.swift new file mode 100644 index 000000000..7427cd46c --- /dev/null +++ b/Tests/TokamakLayoutTests/FrameTests.swift @@ -0,0 +1,127 @@ +// Copyright 2022 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 6/20/22. +// + +#if os(macOS) +import SwiftUI +import TokamakStaticHTML +import XCTest + +final class FrameTests: XCTestCase { + func testFixedFrame() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(width: 200, height: 100) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(width: 200, height: 100) + } + } + + func testFixedWidth() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(width: 200) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(width: 200) + } + } + + func testFixedHeight() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(height: 200) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(height: 200) + } + } + + func testFlexibleFrame() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100) + } + } + + func testFlexibleWidth() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(minWidth: 0, maxWidth: 200) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(minWidth: 0, maxWidth: 200) + } + } + + func testFlexibleHeight() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(minHeight: 0, maxHeight: 200) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(minHeight: 0, maxHeight: 200) + } + } + + func testAlignment() async { + for (nativeHorizontal, tokamakHorizontal) in SwiftUI.HorizontalAlignment.allCases { + for (nativeVertical, tokamakVertical) in SwiftUI.VerticalAlignment.allCases { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(width: 100, height: 100) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .init(horizontal: nativeHorizontal, vertical: nativeVertical) + ) + .background(Color(white: 127 / 255)) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(width: 100, height: 100) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .init( + horizontal: tokamakHorizontal, + vertical: tokamakVertical + ) + ) + .background(Color(white: 127 / 255)) + } + } + } + } +} +#endif diff --git a/Tests/TokamakLayoutTests/StackTests.swift b/Tests/TokamakLayoutTests/StackTests.swift new file mode 100644 index 000000000..66c1e9908 --- /dev/null +++ b/Tests/TokamakLayoutTests/StackTests.swift @@ -0,0 +1,152 @@ +// Copyright 2022 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 6/20/22. +// + +#if os(macOS) +import SwiftUI +import TokamakStaticHTML +import XCTest + +final class StackTests: XCTestCase { + func testVStack() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.VStack { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + } + } to: { + TokamakStaticHTML.VStack { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + } + } + } + + func testHStack() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.HStack { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + } + } to: { + TokamakStaticHTML.HStack { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + } + } + } + + func testVStackSpacing() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.VStack(spacing: 0) { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + } + } to: { + TokamakStaticHTML.VStack(spacing: 0) { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + } + } + } + + func testHStackSpacing() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.HStack(spacing: 0) { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + } + } to: { + TokamakStaticHTML.HStack(spacing: 0) { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + } + } + } + + func testVStackAlignment() async { + for (nativeAlignment, tokamakAlignment) in SwiftUI.HorizontalAlignment.allCases { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.VStack(alignment: nativeAlignment) { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + .frame(width: 50) + } + } to: { + TokamakStaticHTML.VStack(alignment: tokamakAlignment) { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + .frame(width: 50) + } + } + } + } + + func testHStackAlignment() async { + for (nativeAlignment, tokamakAlignment) in SwiftUI.VerticalAlignment.allCases { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.HStack(alignment: nativeAlignment) { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + .frame(height: 50) + } + } to: { + TokamakStaticHTML.HStack(alignment: tokamakAlignment) { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + .frame(height: 50) + } + } + } + } + + func testVStackPriority() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.VStack { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + .frame(minHeight: 50) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + .layoutPriority(1) + } + } to: { + TokamakStaticHTML.VStack { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + .frame(minHeight: 50) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + .layoutPriority(1) + } + } + } + + func testHStackPriority() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.HStack { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + .frame(minHeight: 50) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + .layoutPriority(1) + } + } to: { + TokamakStaticHTML.HStack { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + .frame(minHeight: 50) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + .layoutPriority(1) + } + } + } +} +#endif diff --git a/Tests/TokamakLayoutTests/compare.swift b/Tests/TokamakLayoutTests/compare.swift new file mode 100644 index 000000000..130afe4a0 --- /dev/null +++ b/Tests/TokamakLayoutTests/compare.swift @@ -0,0 +1,115 @@ +// Copyright 2022 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 6/20/22. +// + +#if os(macOS) +import SwiftUI +@_spi(TokamakStaticHTML) import TokamakStaticHTML +import XCTest + +func compare( + size: CGSize, + @SwiftUI.ViewBuilder _ native: () -> A, + @TokamakStaticHTML.ViewBuilder to tokamak: () -> B +) async { + let nativePNG = await render(size: size) { + native() + } + let tokamakPNG = await render(size: size) { + tokamak() + } + + let match = nativePNG == tokamakPNG + XCTAssert(match) + + if !match { + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + // swiftlint:disable:next force_try + try! nativePNG.write(to: cwd.appendingPathComponent("_layouttests_native.png")) + // swiftlint:disable:next force_try + try! tokamakPNG.write(to: cwd.appendingPathComponent("_layouttests_tokamak.png")) + print("You can view the diffs at \(cwd.absoluteString)") + } +} + +@MainActor +private func render( + size: CGSize, + @SwiftUI.ViewBuilder _ view: () -> V +) -> Data { + let bounds = CGRect(origin: .zero, size: .init(width: size.width, height: size.height)) + + let view = NSHostingView(rootView: view().preferredColorScheme(.light)) + view.setFrameSize(bounds.size) + view.layer?.backgroundColor = .white + + let bitmap = view.bitmapImageRepForCachingDisplay(in: bounds)! + view.cacheDisplay(in: bounds, to: bitmap) + + let scale = 1 / (NSScreen.main?.backingScaleFactor ?? 1) + return CIContext().pngRepresentation( + of: CIImage(bitmapImageRep: bitmap)! + .transformed(by: .init(scaleX: scale, y: scale)), + format: .RGBA8, + colorSpace: CGColorSpace(name: CGColorSpace.sRGB)! + )! +} + +private func render( + size: CGSize, + @TokamakStaticHTML.ViewBuilder _ view: () -> V +) async -> Data { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let renderer = StaticHTMLFiberRenderer(useDynamicLayout: true, sceneSize: size) + let html = Data(renderer.render(view()).utf8) + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let renderedPath = cwd.appendingPathComponent("rendered.html") + + // swiftlint:disable:next force_try + try! html.write(to: renderedPath) + let browser = Process() + browser + .launchPath = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" + + var arguments = [ + "--headless", + "--disable-gpu", + "--force-device-scale-factor=1.0", + "--force-color-profile=srgb", + "--hide-scrollbars", + "--screenshot", + renderedPath.path, + ] + + arguments.append("--window-size=\(Int(size.width)),\(Int(size.height))") + + browser.arguments = arguments + browser.terminationHandler = { _ in + let cgImage = NSImage( + contentsOf: cwd.appendingPathComponent("screenshot.png") + )! + .cgImage(forProposedRect: nil, context: nil, hints: nil)! + let png = CIContext().pngRepresentation( + of: CIImage(cgImage: cgImage), + format: .RGBA8, + colorSpace: CGColorSpace(name: CGColorSpace.sRGB)! + )! + continuation.resume(returning: png) + } + browser.launch() + } +} +#endif diff --git a/Tests/TokamakReconcilerTests/VisitorTests.swift b/Tests/TokamakReconcilerTests/VisitorTests.swift index 4d7a763af..ecafc85e5 100644 --- a/Tests/TokamakReconcilerTests/VisitorTests.swift +++ b/Tests/TokamakReconcilerTests/VisitorTests.swift @@ -20,46 +20,75 @@ import XCTest @_spi(TokamakCore) @testable import TokamakCore import TokamakTestRenderer -private struct TestView: View { - struct Counter: View { - @State - private var count = 0 - - var body: some View { - VStack { - Text("\(count)") - HStack { - if count > 0 { - Button("Decrement") { - print("Decrement") - count -= 1 - } - } - if count < 5 { - Button("Increment") { - print("Increment") - if count + 1 >= 5 { - print("Hit 5") - } - count += 1 - } - } - } - } +extension FiberReconciler { + /// Expect a `Fiber` to represent a particular `View` type. + func expect( + _ fiber: Fiber?, + represents viewType: V.Type, + _ message: String? = nil + ) where V: View { + guard case let .view(view, _) = fiber?.content else { + return XCTAssert(false, "Fiber does not exit") + } + if let message = message { + XCTAssert(type(of: view) == viewType, message) + } else { + XCTAssert(type(of: view) == viewType) } } - public var body: some View { - Counter() + /// Expect a `Fiber` to represent a `View` matching`testView`. + func expect( + _ fiber: Fiber?, + equals testView: V, + _ message: String? = nil + ) where V: View & Equatable { + guard case let .view(fiberView, _) = fiber?.content else { + return XCTAssert(false, "Fiber does not exit") + } + if let message = message { + XCTAssertEqual(fiberView as? V, testView, message) + } else { + XCTAssertEqual(fiberView as? V, testView) + } } } final class VisitorTests: XCTestCase { - func testRenderer() { + func testCounter() { + struct TestView: View { + struct Counter: View { + @State + private var count = 0 + + var body: some View { + VStack { + Text("\(count)") + HStack { + if count > 0 { + Button("Decrement") { + count -= 1 + } + } + if count < 5 { + Button("Increment") { + count += 1 + } + } + } + } + } + } + + public var body: some View { + Counter() + } + } let reconciler = TestFiberRenderer(.root, size: .init(width: 500, height: 500)) .render(TestView()) - func decrement() { - guard case let .view(view, _) = reconciler.current // RootView + var hStack: FiberReconciler.Fiber? { + reconciler.current // RootView + .child? // LayoutView .child? // ModifiedContent .child? // _ViewModifier_Content .child? // TestView @@ -67,33 +96,128 @@ final class VisitorTests: XCTestCase { .child? // VStack .child? // TupleView .child?.sibling? // HStack + .child // TupleView + } + var text: FiberReconciler.Fiber? { + reconciler.current // RootView + .child? // LayoutView + .child? // ModifiedContent + .child? // _ViewModifier_Content + .child? // TestView + .child? // Counter + .child? // VStack .child? // TupleView + .child // Text + } + var decrementButton: FiberReconciler.Fiber? { + hStack? .child? // Optional - .child? // Button - .content + .child // Button + } + var incrementButton: FiberReconciler.Fiber? { + hStack? + .child?.sibling? // Optional + .child // Button + } + func decrement() { + guard case let .view(view, _) = decrementButton?.content else { return } (view as? Button)?.action() } func increment() { - guard case let .view(view, _) = reconciler.current // RootView + guard case let .view(view, _) = incrementButton?.content + else { return } + (view as? Button)?.action() + } + // The decrement button is removed when count is < 0 + XCTAssertNil(decrementButton, "'Decrement' should be hidden when count <= 0") + // Count up to 5 + for i in 0..<5 { + reconciler.expect(text, equals: Text("\(i)")) + increment() + } + XCTAssertNil(incrementButton, "'Increment' should be hidden when count >= 5") + reconciler.expect( + decrementButton, + represents: Button.self, + "'Decrement' should be visible when count > 0" + ) + // Count down to 0. + for i in 0..<5 { + reconciler.expect(text, equals: Text("\(5 - i)")) + decrement() + } + XCTAssertNil(decrementButton, "'Decrement' should be hidden when count <= 0") + reconciler.expect( + incrementButton, + represents: Button.self, + "'Increment' should be visible when count < 5" + ) + } + + func testForEach() { + struct TestView: View { + @State + private var count = 0 + + var body: some View { + VStack { + Button("Add Item") { count += 1 } + ForEach(Array(0...Fiber? { + reconciler.current // RootView + .child? // LayoutView .child? // ModifiedContent .child? // _ViewModifier_Content .child? // TestView - .child? // Counter .child? // VStack .child? // TupleView - .child?.sibling? // HStack + .child // Button + } + var forEachFiber: FiberReconciler.Fiber? { + reconciler.current // RootView + .child? // LayoutView + .child? // ModifiedContent + .child? // _ViewModifier_Content + .child? // TestView + .child? // VStack .child? // TupleView - .child? // Optional - .sibling? // Optional - .child? // Button - .content + .child?.sibling // ForEach + } + func item(at index: Int) -> FiberReconciler.Fiber? { + var node = forEachFiber?.child + for _ in 0..)?.action() } - for _ in 0..<5 { - increment() - } - decrement() + reconciler.expect(addItemFiber, represents: Button.self) + reconciler.expect(forEachFiber, represents: ForEach<[Int], Int, Text>.self) + addItem() + reconciler.expect(item(at: 0), equals: Text("Item 0")) + XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 2) + addItem() + reconciler.expect(item(at: 1), equals: Text("Item 1")) + XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 3) + addItem() + reconciler.expect(item(at: 2), equals: Text("Item 2")) + XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 4) } }