From c935744ae8059e17f4b160c5790820635c2446cb Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 15 Jun 2022 10:33:31 -0400 Subject: [PATCH] Add `_ShapeView` and `background` modifiers support to Fiber renderers (#491) * Initial Reconciler using visitor pattern * Preliminary static HTML renderer using the new reconciler * Add environment * Initial DOM renderer * Nearly-working and simplified reconciler * Working reconciler for HTML/DOM renderers * Rename files, and split code across files * Add some documentation and refinements * Remove GraphRendererTests * Initial layout engine (only implemented for the TestRenderer) * Layout engine for the DOM renderer * Refined layout pass * Revise positioning and restoration of position styles on .update * Re-add Optional.body for StackReconciler-based renderers * Add text measurement * Add spacing to StackLayout * Add benchmarks to compare the stack/fiber reconcilers * Fix some issues created for the StackReconciler, and add update benchmarks * Add BenchmarkState.measure to only calculate the time to update * Fix hang in update shallow benchmark * Fix build errors * Address build issues * Remove File.swift headers * Rename Element -> FiberElement and Element.Data -> FiberElement.Content * Add doc comment explaining unowned usage * Add doc comments explaining implicitly unwrapped optionals * Attempt to use Swift instead of JS for applying mutations * Fix issue with not applying updates to DOMFiberElement * Add comment explaining manual implementation of Hashable for PropertyInfo * Fix linter issues * Remove dynamicMember label from subscript * Re-enable carton test * Attempt GTK fix * Add option to disable layout in the FiberReconciler * Re-enable TokamakDemo with StackReconciler * Restore CI config * Restore CI config * Add file headers and cleanup structure * Add 'px' to font-size in test outputs * Remove extra newlines * Keep track of 'elementChildren' so children are positioned in the correct order * Use a ViewVisitor to pass the correct View type to the proposeSize function * Add support for view modifiers * Add frame modifier to demonstrate modifiers * Fix TestRenderer * Remove unused property * Fix doc comment * Fix linter issues and refactor slightly * Fix benchmark builds * Attempt to fix benchmarks * Fix sibling layout issues * Restore original demo * Support overriding visit function in renderer and _ShapeView drawing * Support background modifier * Resolve reconciler issues due to Optionals and elementIndex being set at wrong phase * Remove Brewfile.lock.json * Attempt to fix rendering tests * Formatting nits * Fix Gradient rendering Co-authored-by: Max Desiatov --- Sources/TokamakCore/Fiber/Fiber.swift | 5 + .../Fiber/FiberReconciler+TreeReducer.swift | 25 +- .../TokamakCore/Fiber/FiberReconciler.swift | 17 +- Sources/TokamakCore/Fiber/FiberRenderer.swift | 17 ++ .../Layout/BackgroundLayoutComputer.swift | 85 ++++++ .../Fiber/Layout/FlexLayoutComputer.swift | 11 +- .../Fiber/Layout/FrameLayoutComputer.swift | 11 +- .../Layout/ShrinkWrapLayoutComputer.swift | 11 +- .../TokamakCore/Fiber/LayoutComputer.swift | 12 +- Sources/TokamakCore/Fiber/ViewArguments.swift | 14 +- Sources/TokamakCore/Fiber/ViewVisitor.swift | 2 +- .../Modifiers/StyleModifiers.swift | 13 + Sources/TokamakCore/Shapes/Shape.swift | 4 + Sources/TokamakCore/Views/ViewBuilder.swift | 6 +- Sources/TokamakDOM/DOMFiberRenderer.swift | 25 +- .../Modifiers/LayoutModifiers.swift | 42 +++ .../Modifiers/ViewModifier.swift | 17 ++ .../Modifiers/_BackgroundStyleModifier.swift | 99 +++++-- Sources/TokamakStaticHTML/Shapes/Path.swift | 107 ++++--- .../TokamakStaticHTML/Shapes/_ShapeView.swift | 262 ++++++++++++------ .../StaticHTMLFiberRenderer.swift | 18 +- Sources/TokamakStaticHTML/Views/HTML.swift | 66 ++++- 22 files changed, 666 insertions(+), 203 deletions(-) create mode 100644 Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index 4c0c9975b..d19a0e37d 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -227,6 +227,11 @@ public extension FiberReconciler { } property.set(value: value, on: &content) } + if var environmentReader = content as? EnvironmentReader { + environmentReader.setContent(from: environment) + // swiftlint:disable:next force_cast + content = environmentReader as! T + } return state } diff --git a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift index 6d300d793..8ff5e42df 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift @@ -74,7 +74,7 @@ extension FiberReconciler { update: { fiber, scene, _ in fiber.update(with: &scene) }, - visitChildren: { $0._visitChildren } + visitChildren: { $1._visitChildren } ) } @@ -95,7 +95,9 @@ extension FiberReconciler { update: { fiber, view, elementIndex in fiber.update(with: &view, elementIndex: elementIndex) }, - visitChildren: { $0._visitChildren } + visitChildren: { reconciler, view in + reconciler?.renderer.viewVisitor(for: view) ?? view._visitChildren + } ) } @@ -105,7 +107,7 @@ extension FiberReconciler { createFiber: (inout T, Renderer.ElementType?, Fiber?, Fiber?, Int?, FiberReconciler?) -> Fiber, update: (Fiber, inout T, Int?) -> Renderer.ElementType.Content?, - visitChildren: (T) -> (TreeReducer.SceneVisitor) -> () + visitChildren: (FiberReconciler?, T) -> (TreeReducer.SceneVisitor) -> () ) { // Create the node and its element. var nextValue = nextValue @@ -125,7 +127,7 @@ extension FiberReconciler { ) resultChild = Result( fiber: existing, - visitChildren: visitChildren(nextValue), + visitChildren: visitChildren(partialResult.fiber?.reconciler, nextValue), parent: partialResult, child: existing.child, alternateChild: existing.alternate?.child, @@ -134,13 +136,6 @@ extension FiberReconciler { layoutContexts: partialResult.layoutContexts ) partialResult.nextExisting = existing.sibling - - // If this fiber has an element, increment the elementIndex for its parent. - if let key = key, - existing.element != nil - { - partialResult.elementIndices[key] = partialResult.elementIndices[key, default: 0] + 1 - } } else { let elementParent = partialResult.fiber?.element != nil ? partialResult.fiber @@ -165,15 +160,9 @@ extension FiberReconciler { fiber.alternate = alternate partialResult.nextExistingAlternate = alternate.sibling } - // If this fiber has an element, increment the elementIndex for its parent. - if let key = key, - fiber.element != nil - { - partialResult.elementIndices[key] = partialResult.elementIndices[key, default: 0] + 1 - } resultChild = Result( fiber: fiber, - visitChildren: visitChildren(nextValue), + visitChildren: visitChildren(partialResult.fiber?.reconciler, nextValue), parent: partialResult, child: nil, alternateChild: fiber.alternate?.child, diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index 423186ab9..a85c593c9 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -128,7 +128,7 @@ public final class FiberReconciler { } func visit(_ view: V) where V: View { - visitAny(view, visitChildren: view._visitChildren) + visitAny(view, visitChildren: reconciler.renderer.viewVisitor(for: view)) } func visit(_ scene: S) where S: Scene { @@ -224,8 +224,7 @@ public final class FiberReconciler { let previous = node.fiber?.alternate?.element { // This is a completely different type of view. - mutations - .append(.replace(parent: parent, previous: previous, replacement: element)) + mutations.append(.replace(parent: parent, previous: previous, replacement: element)) } else if let newContent = node.newContent, newContent != element.content { @@ -327,15 +326,25 @@ public final class FiberReconciler { /// 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) - elementIndices = node.elementIndices // As we walk down the tree, propose a size for each View. if reconciler.renderer.useDynamicLayout, diff --git a/Sources/TokamakCore/Fiber/FiberRenderer.swift b/Sources/TokamakCore/Fiber/FiberRenderer.swift index 9b99394bf..45d88f364 100644 --- a/Sources/TokamakCore/Fiber/FiberRenderer.swift +++ b/Sources/TokamakCore/Fiber/FiberRenderer.swift @@ -23,6 +23,9 @@ public protocol FiberRenderer { associatedtype ElementType: FiberElement /// Check whether a `View` is a primitive for this renderer. static func isPrimitive(_ view: V) -> Bool where V: View + func visitPrimitiveChildren( + _ view: Primitive + ) -> ViewVisitorF? where Primitive: View, Visitor: ViewVisitor /// Apply the mutations to the elements. func commit(_ mutations: [Mutation]) /// The root element all top level views should be mounted on. @@ -40,6 +43,20 @@ public protocol FiberRenderer { public extension FiberRenderer { var defaultEnvironment: EnvironmentValues { .init() } + func visitPrimitiveChildren( + _ view: Primitive + ) -> ViewVisitorF? where Primitive: View, Visitor: ViewVisitor { + nil + } + + func viewVisitor(for view: V) -> ViewVisitorF { + if Self.isPrimitive(view) { + return visitPrimitiveChildren(view) ?? view._visitChildren + } else { + return view._visitChildren + } + } + @discardableResult func render(_ view: V) -> FiberReconciler { .init(self, view) diff --git a/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift new file mode 100644 index 000000000..b4552bd98 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift @@ -0,0 +1,85 @@ +// 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/FlexLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift index fb8b93244..4dd3b63c2 100644 --- a/Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift @@ -18,24 +18,25 @@ import Foundation /// A `LayoutComputer` that fills its parent. -struct FlexLayoutComputer: LayoutComputer { +@_spi(TokamakCore) +public struct FlexLayoutComputer: LayoutComputer { let proposedSize: CGSize - init(proposedSize: CGSize) { + public init(proposedSize: CGSize) { self.proposedSize = proposedSize } - func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize + public func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize where V: View { proposedSize } - func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { + public func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { .zero } - func requestSize(in context: LayoutContext) -> CGSize { + public func requestSize(in context: LayoutContext) -> CGSize { proposedSize } } diff --git a/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift index 535dcbf79..2f67f0448 100644 --- a/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift @@ -18,26 +18,27 @@ import Foundation /// A `LayoutComputer` that uses a specified size in one or more axes. -struct FrameLayoutComputer: LayoutComputer { +@_spi(TokamakCore) +public struct FrameLayoutComputer: LayoutComputer { let proposedSize: CGSize let width: CGFloat? let height: CGFloat? let alignment: Alignment - init(proposedSize: CGSize, width: CGFloat?, height: CGFloat?, alignment: Alignment) { + public init(proposedSize: CGSize, width: CGFloat?, height: CGFloat?, alignment: Alignment) { self.proposedSize = proposedSize self.width = width self.height = height self.alignment = alignment } - func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize + 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) } - func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { + public func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { let size = ViewDimensions( size: .init( width: width ?? child.dimensions.width, @@ -51,7 +52,7 @@ struct FrameLayoutComputer: LayoutComputer { ) } - func requestSize(in context: LayoutContext) -> CGSize { + public func requestSize(in context: LayoutContext) -> CGSize { let childSize = context.children.reduce(CGSize.zero) { .init( width: max($0.width, $1.dimensions.width), diff --git a/Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift index 7d312bf1c..92df4fa1d 100644 --- a/Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift @@ -18,24 +18,25 @@ import Foundation /// A `LayoutComputer` that shrinks to the size of its children. -struct ShrinkWrapLayoutComputer: LayoutComputer { +@_spi(TokamakCore) +public struct ShrinkWrapLayoutComputer: LayoutComputer { let proposedSize: CGSize - init(proposedSize: CGSize) { + public init(proposedSize: CGSize) { self.proposedSize = proposedSize } - func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize + public func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize where V: View { proposedSize } - func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { + public func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { .zero } - func requestSize(in context: LayoutContext) -> CGSize { + public func requestSize(in context: LayoutContext) -> CGSize { context.children.reduce(CGSize.zero) { .init( width: max($0.width, $1.dimensions.width), diff --git a/Sources/TokamakCore/Fiber/LayoutComputer.swift b/Sources/TokamakCore/Fiber/LayoutComputer.swift index da257ac60..0eee3f398 100644 --- a/Sources/TokamakCore/Fiber/LayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/LayoutComputer.swift @@ -18,12 +18,12 @@ import Foundation /// The currently computed children. -struct LayoutContext { - var children: [Child] +public struct LayoutContext { + public var children: [Child] - struct Child { - let index: Int - let dimensions: ViewDimensions + public struct Child { + public let index: Int + public let dimensions: ViewDimensions } } @@ -40,7 +40,7 @@ struct LayoutContext { /// 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. -protocol LayoutComputer { +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 diff --git a/Sources/TokamakCore/Fiber/ViewArguments.swift b/Sources/TokamakCore/Fiber/ViewArguments.swift index e592a10a8..8cc66ee07 100644 --- a/Sources/TokamakCore/Fiber/ViewArguments.swift +++ b/Sources/TokamakCore/Fiber/ViewArguments.swift @@ -19,8 +19,9 @@ import Foundation /// Data passed to `_makeView` to create the `ViewOutputs` used in reconciling/rendering. public struct ViewInputs { - let content: V - let environment: EnvironmentBox + public let content: V + @_spi(TokamakCore) + public let environment: EnvironmentBox } /// Data used to reconcile and render a `View` and its children. @@ -34,15 +35,16 @@ public struct ViewOutputs { var layoutComputer: LayoutComputer! } -final class EnvironmentBox { - let environment: EnvironmentValues +@_spi(TokamakCore) +public final class EnvironmentBox { + public let environment: EnvironmentValues - init(_ environment: EnvironmentValues) { + public init(_ environment: EnvironmentValues) { self.environment = environment } } -extension ViewOutputs { +public extension ViewOutputs { init( inputs: ViewInputs, environment: EnvironmentValues? = nil, diff --git a/Sources/TokamakCore/Fiber/ViewVisitor.swift b/Sources/TokamakCore/Fiber/ViewVisitor.swift index c4fe58f37..0aa02c9ad 100644 --- a/Sources/TokamakCore/Fiber/ViewVisitor.swift +++ b/Sources/TokamakCore/Fiber/ViewVisitor.swift @@ -26,7 +26,7 @@ public extension View { } } -typealias ViewVisitorF = (V) -> () +public typealias ViewVisitorF = (V) -> () /// A type that creates a `Result` by visiting multiple `View`s. protocol ViewReducer { diff --git a/Sources/TokamakCore/Modifiers/StyleModifiers.swift b/Sources/TokamakCore/Modifiers/StyleModifiers.swift index 5a016a999..46433bec4 100644 --- a/Sources/TokamakCore/Modifiers/StyleModifiers.swift +++ b/Sources/TokamakCore/Modifiers/StyleModifiers.swift @@ -24,6 +24,19 @@ public struct _BackgroundLayout: _PrimitiveView public let content: Content public let background: Background public let alignment: Alignment + + @_spi(TokamakCore) + public init(content: Content, background: Background, alignment: Alignment) { + self.content = content + self.background = background + self.alignment = alignment + } + + 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) + } } public struct _BackgroundModifier: ViewModifier, EnvironmentReader diff --git a/Sources/TokamakCore/Shapes/Shape.swift b/Sources/TokamakCore/Shapes/Shape.swift index 1eeecf596..17c1dd6ca 100644 --- a/Sources/TokamakCore/Shapes/Shape.swift +++ b/Sources/TokamakCore/Shapes/Shape.swift @@ -70,6 +70,10 @@ public struct _ShapeView: _PrimitiveView where Content: Shape, S self.style = style self.fillStyle = fillStyle } + + public static func _makeView(_ inputs: ViewInputs<_ShapeView>) -> ViewOutputs { + .init(inputs: inputs, layoutComputer: FlexLayoutComputer.init) + } } public extension Shape { diff --git a/Sources/TokamakCore/Views/ViewBuilder.swift b/Sources/TokamakCore/Views/ViewBuilder.swift index dca1465d1..f9fcbd07b 100644 --- a/Sources/TokamakCore/Views/ViewBuilder.swift +++ b/Sources/TokamakCore/Views/ViewBuilder.swift @@ -72,12 +72,14 @@ extension Optional: View where Wrapped: View { } } -protocol AnyOptional { +@_spi(TokamakCore) +public protocol AnyOptional { var value: Any? { get } } +@_spi(TokamakCore) extension Optional: AnyOptional { - var value: Any? { + public var value: Any? { switch self { case let .some(value): return value case .none: return nil diff --git a/Sources/TokamakDOM/DOMFiberRenderer.swift b/Sources/TokamakDOM/DOMFiberRenderer.swift index afd4e1276..bbcbd4742 100644 --- a/Sources/TokamakDOM/DOMFiberRenderer.swift +++ b/Sources/TokamakDOM/DOMFiberRenderer.swift @@ -27,13 +27,17 @@ public final class DOMElement: FiberElement { public struct Content: FiberElementContent { let tag: String + let namespace: String? let attributes: [HTMLAttribute: String] let innerHTML: String? let listeners: [String: Listener] let debugData: [String: ConvertibleToJSValue] public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.tag == rhs.tag && lhs.attributes == rhs.attributes && lhs.innerHTML == rhs.innerHTML + lhs.tag == rhs.tag + && lhs.namespace == rhs.namespace + && lhs.attributes == rhs.attributes + && lhs.innerHTML == rhs.innerHTML } } @@ -52,6 +56,7 @@ public extension DOMElement.Content { init(from primitiveView: V, useDynamicLayout: Bool) where V: View { guard let primitiveView = primitiveView as? HTMLConvertible else { fatalError() } tag = primitiveView.tag + namespace = primitiveView.namespace attributes = primitiveView.attributes(useDynamicLayout: useDynamicLayout) innerHTML = primitiveView.innerHTML @@ -96,6 +101,7 @@ public struct DOMFiberRenderer: FiberRenderer { rootElement = .init( from: .init( tag: "", + namespace: nil, attributes: [:], innerHTML: nil, listeners: [:], @@ -115,11 +121,24 @@ public struct DOMFiberRenderer: FiberRenderer { } public static func isPrimitive(_ view: V) -> Bool where V: View { - view is HTMLConvertible || view is DOMNodeConvertible + !(view is AnyOptional) && + (view is HTMLConvertible || view is DOMNodeConvertible) + } + + public func visitPrimitiveChildren( + _ view: Primitive + ) -> ViewVisitorF? where Primitive: View, Visitor: ViewVisitor { + guard let primitive = view as? HTMLConvertible else { return nil } + return primitive.primitiveVisitor(useDynamicLayout: useDynamicLayout) } private func createElement(_ element: DOMElement) -> JSObject { - let result = document.createElement!(element.content.tag).object! + let result: JSObject + if let namespace = element.content.namespace { + result = document.createElementNS!(namespace, element.content.tag).object! + } else { + result = document.createElement!(element.content.tag).object! + } apply(element.content, to: result) element.reference = result return result diff --git a/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift b/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift index 7f5a4e6fe..982f3677d 100644 --- a/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift +++ b/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift @@ -200,6 +200,48 @@ extension _BackgroundLayout: _HTMLPrimitive { } } +@_spi(TokamakStaticHTML) +extension _BackgroundLayout: 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 { + 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; + + width: 0; min-width: 100%; + height: 0; min-height: 100%; + overflow: hidden; + """] + ) { + background + }) + $0.visit(HTML("div", ["style": "grid-area: a;"]) { + content + }) + } + } + } +} + extension _OverlayLayout: _HTMLPrimitive { public var renderedBody: AnyView { AnyView( diff --git a/Sources/TokamakStaticHTML/Modifiers/ViewModifier.swift b/Sources/TokamakStaticHTML/Modifiers/ViewModifier.swift index 4972143d8..4d86750ae 100644 --- a/Sources/TokamakStaticHTML/Modifiers/ViewModifier.swift +++ b/Sources/TokamakStaticHTML/Modifiers/ViewModifier.swift @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +@_spi(TokamakCore) import TokamakCore public protocol DOMViewModifier { @@ -45,6 +46,14 @@ extension _ZIndexModifier: DOMViewModifier { } } +@_spi(TokamakStaticHTML) +public protocol HTMLModifierConvertible { + func primitiveVisitor( + content: Content, + useDynamicLayout: Bool + ) -> ((V) -> ())? where V: ViewVisitor +} + @_spi(TokamakStaticHTML) extension ModifiedContent: HTMLConvertible where Content: View, Modifier: HTMLConvertible @@ -55,4 +64,12 @@ extension ModifiedContent: HTMLConvertible where Content: View, } public var innerHTML: String? { modifier.innerHTML } + + public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V: ViewVisitor { + (modifier as? HTMLModifierConvertible)? + .primitiveVisitor( + content: content, + useDynamicLayout: useDynamicLayout + ) + } } diff --git a/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift b/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift index 084828c41..5128d3dac 100644 --- a/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift +++ b/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift @@ -12,40 +12,47 @@ // See the License for the specific language governing permissions and // limitations under the License. -import TokamakCore +@_spi(TokamakCore) import TokamakCore extension _BackgroundStyleModifier: DOMViewModifier { public var isOrderDependent: Bool { true } + private func attributes( + for material: _MaterialStyle, + color: AnyColorBox.ResolvedValue + ) -> [HTMLAttribute: String] { + let blur: (opacity: Double, radius: Double) + switch material { + case .ultraThin: + blur = (0.2, 20) + case .thin: + blur = (0.4, 25) + case .regular: + blur = (0.5, 30) + case .thick: + blur = (0.6, 40) + case .ultraThick: + blur = (0.6, 50) + } + return [ + "style": + """ + background-color: rgba(\(color.red * 255), \(color.green * 255), \(color + .blue * 255), \(blur + .opacity)); + -webkit-backdrop-filter: blur(\(blur.radius)px); + backdrop-filter: blur(\(blur.radius)px); + """, + ] + } + public var attributes: [HTMLAttribute: String] { if let resolved = style.resolve( for: .resolveStyle(levels: 0..<1), in: environment, role: .fill ) { - if case let .foregroundMaterial(rgba, material) = resolved { - let blur: (opacity: Double, radius: Double) - switch material { - case .ultraThin: - blur = (0.2, 20) - case .thin: - blur = (0.4, 25) - case .regular: - blur = (0.5, 30) - case .thick: - blur = (0.6, 40) - case .ultraThick: - blur = (0.6, 50) - } - return [ - "style": - """ - background-color: rgba(\(rgba.red * 255), \(rgba.green * 255), \(rgba - .blue * 255), \(blur - .opacity)); - -webkit-backdrop-filter: blur(\(blur.radius)px); - backdrop-filter: blur(\(blur.radius)px); - """, - ] + if case let .foregroundMaterial(color, material) = resolved { + return attributes(for: material, color: color) } else if let color = resolved.color(at: 0) { return [ "style": "background-color: \(color.cssValue(environment));", @@ -55,3 +62,45 @@ extension _BackgroundStyleModifier: DOMViewModifier { return [:] } } + +@_spi(TokamakStaticHTML) +extension _BackgroundStyleModifier: HTMLConvertible, + HTMLModifierConvertible +{ + public var tag: String { "div" } + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + let resolved = style.resolve( + for: .resolveStyle(levels: 0..<1), + in: environment, + role: .fill + ) + if case let .foregroundMaterial(color, material) = resolved { + return attributes(for: material, color: color) + } else { + return [:] + } + } + + public func primitiveVisitor( + content: Content, + useDynamicLayout: Bool + ) -> ((V) -> ())? where V: ViewVisitor, Content: View { + let resolved = style.resolve( + for: .resolveStyle(levels: 0..<1), + in: environment, + role: .fill + ) + if case .foregroundMaterial = resolved { + return nil + } else { + return { + $0 + .visit(_BackgroundLayout( + content: content, + background: Rectangle().fill(style), + alignment: .center + )) + } + } + } +} diff --git a/Sources/TokamakStaticHTML/Shapes/Path.swift b/Sources/TokamakStaticHTML/Shapes/Path.swift index 470ddffc7..a5735535d 100644 --- a/Sources/TokamakStaticHTML/Shapes/Path.swift +++ b/Sources/TokamakStaticHTML/Shapes/Path.swift @@ -16,6 +16,7 @@ // import Foundation +@_spi(TokamakCore) import TokamakCore extension StrokeStyle { @@ -29,7 +30,7 @@ extension Path: _HTMLPrimitive { func svgFrom( storage: Storage, strokeStyle: StrokeStyle = .zero - ) -> AnyView { + ) -> HTML? { let stroke: [HTMLAttribute: String] = [ "stroke-width": "\(strokeStyle.lineWidth)", ] @@ -40,40 +41,57 @@ extension Path: _HTMLPrimitive { let flexibleCenterY: String? = sizing == .flexible ? "50%" : nil switch storage { case .empty: - return AnyView(EmptyView()) + return nil case let .rect(rect): - return AnyView(AnyView(HTML("rect", [ - "width": flexibleWidth ?? "\(max(0, rect.size.width))", - "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)))) + return HTML( + "rect", + namespace: namespace, + [ + "width": flexibleWidth ?? "\(max(0, rect.size.width))", + "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 + ) + } + } + ) case let .ellipse(rect): - return AnyView(HTML( + return HTML( "ellipse", + namespace: namespace, ["cx": flexibleCenterX ?? "\(rect.origin.x)", "cy": flexibleCenterY ?? "\(rect.origin.y)", "rx": flexibleCenterX ?? "\(rect.size.width)", "ry": flexibleCenterY ?? "\(rect.size.height)"] .merging(stroke, uniquingKeysWith: uniqueKeys) - )) + ) case let .roundedRect(roundedRect): // When cornerRadius is nil we use 50% rx. let size = roundedRect.rect.size - let cornerRadius = { () -> [HTMLAttribute: String] in - if let cornerSize = roundedRect.cornerSize { - return [ - "rx": "\(cornerSize.width)", - "ry": "\(roundedRect.style == .continuous ? cornerSize.width : cornerSize.height)", - ] - } else { - // For this to support vertical capsules, we need - // GeometryReader, to know which axis is larger. - return ["ry": "50%"] - } - }() - return AnyView(HTML( + let cornerRadius: [HTMLAttribute: String] + if let cornerSize = roundedRect.cornerSize { + cornerRadius = [ + "rx": "\(cornerSize.width)", + "ry": "\(roundedRect.style == .continuous ? cornerSize.width : cornerSize.height)", + ] + } else { + // For this to support vertical capsules, we need + // GeometryReader, to know which axis is larger. + cornerRadius = ["ry": "50%"] + } + return HTML( "rect", + namespace: namespace, [ "width": flexibleWidth ?? "\(size.width)", "height": flexibleHeight ?? "\(size.height)", @@ -82,9 +100,9 @@ extension Path: _HTMLPrimitive { ] .merging(cornerRadius, uniquingKeysWith: uniqueKeys) .merging(stroke, uniquingKeysWith: uniqueKeys) - )) + ) case let .stroked(stroked): - return AnyView(stroked.path.svgBody(strokeStyle: stroked.style)) + return stroked.path.svgBody(strokeStyle: stroked.style) case let .trimmed(trimmed): return trimmed.path.svgFrom( storage: trimmed.path.storage, @@ -98,8 +116,8 @@ extension Path: _HTMLPrimitive { func svgFrom( elements: [Element], strokeStyle: StrokeStyle = .zero - ) -> AnyView { - if elements.isEmpty { return AnyView(EmptyView()) } + ) -> HTML? { + if elements.isEmpty { return nil } var d = [String]() for element in elements { switch element { @@ -115,10 +133,10 @@ extension Path: _HTMLPrimitive { d.append("Z") } } - return AnyView(HTML("path", [ + return HTML("path", namespace: namespace, [ "style": "stroke-width: \(strokeStyle.lineWidth);", "d": d.joined(separator: "\n"), - ])) + ]) } var size: CGSize { boundingRect.size } @@ -126,13 +144,12 @@ extension Path: _HTMLPrimitive { @ViewBuilder func svgBody( strokeStyle: StrokeStyle = .zero - ) -> some View { + ) -> HTML? { svgFrom(storage: storage, strokeStyle: strokeStyle) } - @_spi(TokamakStaticHTML) - public var renderedBody: AnyView { - let sizeStyle = sizing == .flexible ? + var sizeStyle: String { + sizing == .flexible ? """ width: 100%; height: 100%; @@ -141,7 +158,11 @@ extension Path: _HTMLPrimitive { width: \(max(0, size.width)); height: \(max(0, size.height)); """ - return AnyView(HTML("svg", ["style": """ + } + + @_spi(TokamakStaticHTML) + public var renderedBody: AnyView { + AnyView(HTML("svg", ["style": """ \(sizeStyle) overflow: visible; """]) { @@ -149,3 +170,21 @@ extension Path: _HTMLPrimitive { }) } } + +@_spi(TokamakStaticHTML) +extension Path: HTMLConvertible { + public var tag: String { "svg" } + public var namespace: String? { "http://www.w3.org/2000/svg" } + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + guard !useDynamicLayout else { return [:] } + return [ + "style": """ + \(sizeStyle) + """, + ] + } + + public var innerHTML: String? { + svgBody()?.outerHTML(shouldSortAttributes: false, children: []) + } +} diff --git a/Sources/TokamakStaticHTML/Shapes/_ShapeView.swift b/Sources/TokamakStaticHTML/Shapes/_ShapeView.swift index e6235e429..ec923abdd 100644 --- a/Sources/TokamakStaticHTML/Shapes/_ShapeView.swift +++ b/Sources/TokamakStaticHTML/Shapes/_ShapeView.swift @@ -53,90 +53,44 @@ extension _StrokedShape: ShapeAttributes { } } +private struct GradientID: Hashable { + let stops: [StopID] + + init(_ gradient: Gradient) { + stops = gradient.stops.map(StopID.init) + } + + struct StopID: Hashable { + let color: Color + let location: CGFloat + + init(_ stop: Gradient.Stop) { + color = stop.color + location = stop.location + } + } +} + extension _ShapeView: _HTMLPrimitive { - @_spi(TokamakStaticHTML) - public var renderedBody: AnyView { - let path = shape.path(in: .zero).renderedBody - var attributes: [HTMLAttribute: String] = [:] - var svgDefs: AnyView? - var cssGradient: String? + private func gradientID(for gradient: Gradient, in style: _GradientStyle) -> String { + "gradient\(GradientID(gradient).hashValue)___\(style.hashValue)" + } + func attributes(resolvedStyle: _ResolvedStyle?) -> [HTMLAttribute: String] { if let shapeAttributes = shape as? ShapeAttributes { - attributes = shapeAttributes.attributes(style) + return shapeAttributes.attributes(style) } else { - let resolved = style.resolve( - for: .resolveStyle(levels: 0..<1), - in: environment, - role: Content.role - ) - switch resolved { + switch resolvedStyle { case let .gradient(gradient, style): - let stops = ForEach(Array(gradient.stops.enumerated()), id: \.offset) { - HTML("stop", [ - "offset": "\($0.element.location * 100)%", - "stop-color": $0.element.color.cssValue(environment), - ]) - } - let id = Int.random(in: 0.. 0 { - cssStops - .append("\(gradient.stops.last!.color.cssValue(environment)) \(50.0 + 50 * ratio)%") - cssStops - .append( - "\(gradient.stops.first!.color.cssValue(environment)) \(50.0 + 50 * ratio)%" - ) - } - if cssStops.count == 1 { - cssStops.append(cssStops[0]) - } - cssGradient = "background:conic-gradient(from \(startAngle.degrees + 90)deg at " + - "\(center.x * 100)% \(center.y * 100)%, " + - "\(cssStops.joined(separator: ", ")));" - attributes["style"] = nil - default: return path + + if case .angular = style { + return [:] + } else { + return ["style": "fill: url(#\(gradientID(for: gradient, in: style)));"] } default: - if let color = resolved?.color(at: 0) { - attributes = ["style": "fill: \(color.cssValue(environment));"] + if let color = resolvedStyle?.color(at: 0) { + return ["style": "fill: \(color.cssValue(environment));"] } else if let foregroundStyle = environment._foregroundStyle, let color = foregroundStyle.resolve( @@ -145,32 +99,118 @@ extension _ShapeView: _HTMLPrimitive { role: Content.role )?.color(at: 0) { - attributes = ["style": "fill: \(color.cssValue(environment));"] + return ["style": "fill: \(color.cssValue(environment));"] } else { - return path + return [:] } } } + } + + func svgDefinitions(resolvedStyle: _ResolvedStyle?) + -> HTML.Element], Int, HTML>>? + { + guard case let .gradient(gradient, style) = resolvedStyle else { return nil } + let stops = ForEach(Array(gradient.stops.enumerated()), id: \.offset) { + HTML("stop", namespace: namespace, [ + "offset": "\($0.element.location * 100)%", + "stop-color": $0.element.color.cssValue(environment), + ]) + } + switch style { + case let .linear(startPoint, endPoint): + return HTML( + "linearGradient", + namespace: namespace, + [ + "id": gradientID(for: gradient, in: style), + "x1": "\(startPoint.x * 100)%", + "y1": "\(startPoint.y * 100)%", + "x2": "\(endPoint.x * 100)%", + "y2": "\(endPoint.y * 100)%", + "gradientUnits": "userSpaceOnUse", + ] + ) { + stops + } + case let .radial(center, startRadius, endRadius): + return HTML( + "radialGradient", + namespace: namespace, + [ + "id": gradientID(for: gradient, in: style), + "fx": "\(center.x * 100)%", + "fy": "\(center.y * 100)%", + "cx": "\(center.x * 100)%", + "cy": "\(center.y * 100)%", + "gradientUnits": "userSpaceOnUse", + "fr": "\(startRadius)", + "r": "\(endRadius)", + ] + ) { + stops + } + default: return nil + } + } + + func cssGradient(resolvedStyle: _ResolvedStyle?) -> String? { + guard case let .gradient(gradient, .angular(center, startAngle, endAngle)) = resolvedStyle + else { return nil } + let ratio = CGFloat((endAngle - startAngle).degrees / 360.0) + var cssStops = gradient.stops.enumerated().map { + $0.element.color.cssValue(environment) + " \($0.element.location * 100.0 * ratio)%" + } + if ratio < 1.0 && cssStops.count > 0 { + cssStops + .append("\(gradient.stops.last!.color.cssValue(environment)) \(50.0 + 50 * ratio)%") + cssStops + .append( + "\(gradient.stops.first!.color.cssValue(environment)) \(50.0 + 50 * ratio)%" + ) + } + if cssStops.count == 1 { + cssStops.append(cssStops[0]) + } + return "background:conic-gradient(from \(startAngle.degrees + 90)deg at " + + "\(center.x * 100)% \(center.y * 100)%, " + + "\(cssStops.joined(separator: ", ")));" + } + + @_spi(TokamakStaticHTML) + public var renderedBody: AnyView { + let path = shape.path(in: .zero).renderedBody - if let view = mapAnyView(path, transform: { (html: HTML) -> AnyView in + let resolvedStyle = style.resolve( + for: .resolveStyle(levels: 0..<1), + in: environment, + role: Content.role + ) + + if let view = mapAnyView(path, transform: { (html: HTML?>) -> AnyView in let uniqueKeys = { (first: String, second: String) in "\(first) \(second)" } let mergedAttributes = html.attributes.merging( - attributes, + attributes(resolvedStyle: resolvedStyle), uniquingKeysWith: uniqueKeys ) return AnyView(HTML(html.tag, mergedAttributes) { - if let cssGradient = cssGradient { - HTML("clipPath", ["id": "clip", "width": "100%", "height": "100%"]) { + if let cssGradient = cssGradient(resolvedStyle: resolvedStyle) { + HTML( + "clipPath", + namespace: namespace, + ["id": "clip", "width": "100%", "height": "100%"] + ) { html.content } HTML( "foreignObject", + namespace: namespace, ["clip-path": "url(#clip)", "width": "100%", "height": "100%", "style": cssGradient] ) } else { html.content - if let svgDefs = svgDefs { - HTML("defs") { + if let svgDefs = svgDefinitions(resolvedStyle: resolvedStyle) { + HTML("defs", namespace: namespace) { svgDefs } } @@ -183,3 +223,55 @@ extension _ShapeView: _HTMLPrimitive { } } } + +@_spi(TokamakStaticHTML) +extension _ShapeView: HTMLConvertible { + public var tag: String { "svg" } + public var namespace: String? { "http://www.w3.org/2000/svg" } + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + let resolvedStyle = style.resolve( + for: .resolveStyle(levels: 0..<1), + in: environment, + role: Content.role + ) + return attributes(resolvedStyle: resolvedStyle) + } + + public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V: ViewVisitor { + let resolvedStyle = style.resolve( + for: .resolveStyle(levels: 0..<1), + in: environment, + role: Content.role + ) + let path = shape.path(in: .zero).svgBody() + return { + if let cssGradient = cssGradient(resolvedStyle: resolvedStyle) { + $0 + .visit(HTML( + "clipPath", + namespace: namespace, + ["id": "clip", "width": "100%", "height": "100%"] + ) { + path + }) + $0.visit(HTML( + "foreignObject", + namespace: namespace, + [ + "clip-path": "url(#clip)", + "width": "100%", + "height": "100%", + "style": cssGradient, + ] + )) + } else { + $0.visit(path) + if let svgDefs = svgDefinitions(resolvedStyle: resolvedStyle) { + $0.visit(HTML("defs", namespace: namespace) { + svgDefs + }) + } + } + } + } +} diff --git a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift index a65b73f70..ccff8f56c 100644 --- a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift +++ b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift @@ -92,13 +92,20 @@ public final class HTMLElement: FiberElement, CustomStringConvertible { @_spi(TokamakStaticHTML) public protocol HTMLConvertible { var tag: String { get } + var namespace: String? { get } func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] var innerHTML: String? { get } + func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? } public extension HTMLConvertible { + @_spi(TokamakStaticHTML) + var namespace: String? { nil } @_spi(TokamakStaticHTML) var innerHTML: String? { nil } + func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? { + nil + } } @_spi(TokamakStaticHTML) @@ -108,6 +115,7 @@ extension VStack: HTMLConvertible { @_spi(TokamakStaticHTML) public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + guard !useDynamicLayout else { return [:] } let spacing = _VStackProxy(self).spacing return [ "style": """ @@ -128,6 +136,7 @@ extension HStack: HTMLConvertible { @_spi(TokamakStaticHTML) public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + guard !useDynamicLayout else { return [:] } let spacing = _HStackProxy(self).spacing return [ "style": """ @@ -155,7 +164,14 @@ public struct StaticHTMLFiberRenderer: FiberRenderer { } public static func isPrimitive(_ view: V) -> Bool where V: View { - view is HTMLConvertible + !(view is AnyOptional) && view is HTMLConvertible + } + + public func visitPrimitiveChildren( + _ view: Primitive + ) -> ViewVisitorF? where Primitive: View, Visitor: ViewVisitor { + guard let primitive = view as? HTMLConvertible else { return nil } + return primitive.primitiveVisitor(useDynamicLayout: useDynamicLayout) } public func commit(_ mutations: [Mutation]) { diff --git a/Sources/TokamakStaticHTML/Views/HTML.swift b/Sources/TokamakStaticHTML/Views/HTML.swift index 916e4c4c8..2577681ec 100644 --- a/Sources/TokamakStaticHTML/Views/HTML.swift +++ b/Sources/TokamakStaticHTML/Views/HTML.swift @@ -15,6 +15,8 @@ // Created by Max Desiatov on 11/04/2020. // +import Foundation +@_spi(TokamakCore) import TokamakCore /** Represents an attribute of an HTML tag. To consume updates from updated attributes, the DOM @@ -87,8 +89,11 @@ public extension AnyHTML { public struct HTML: View, AnyHTML { public let tag: String + public let namespace: String? public let attributes: [HTMLAttribute: String] let content: Content + let layoutComputer: (CGSize) -> LayoutComputer + let visitContent: (ViewVisitor) -> () fileprivate let cachedInnerHTML: String? @@ -98,33 +103,66 @@ public struct HTML: View, AnyHTML { @_spi(TokamakCore) public var body: Never { - neverBody("HTML") + neverBody("HTML<\(Content.self)>") + } + + public func _visitChildren(_ visitor: V) where V: ViewVisitor { + visitContent(visitor) + } + + public static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + .init(inputs: inputs, layoutComputer: inputs.content.layoutComputer) } } public extension HTML where Content: StringProtocol { init( _ tag: String, + namespace: String? = nil, _ attributes: [HTMLAttribute: String] = [:], content: Content ) { self.tag = tag + self.namespace = namespace self.attributes = attributes self.content = content + layoutComputer = ShrinkWrapLayoutComputer.init cachedInnerHTML = String(content) + visitContent = { _ in } } } extension HTML: ParentView where Content: View { public init( _ tag: String, + namespace: String? = nil, _ attributes: [HTMLAttribute: String] = [:], - @ViewBuilder content: () -> Content + @ViewBuilder content: @escaping () -> Content ) { self.tag = tag + self.namespace = namespace self.attributes = attributes 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()) } } @_spi(TokamakCore) @@ -136,10 +174,32 @@ extension HTML: ParentView where Content: View { public extension HTML where Content == EmptyView { init( _ tag: String, + namespace: String? = nil, _ attributes: [HTMLAttribute: String] = [:] ) { - self = HTML(tag, attributes) { EmptyView() } + self = HTML(tag, namespace: namespace, attributes) { EmptyView() } } + + @_spi(TokamakCore) + init( + _ tag: String, + namespace: String? = nil, + _ attributes: [HTMLAttribute: String] = [:], + layoutComputer: @escaping (CGSize) -> LayoutComputer + ) { + self = HTML(tag, namespace: namespace, attributes, layoutComputer: layoutComputer) { + EmptyView() + } + } +} + +@_spi(TokamakStaticHTML) +extension HTML: HTMLConvertible { + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + attributes + } + + public var innerHTML: String? { cachedInnerHTML } } public protocol StylesConvertible {