diff --git a/Sources/TokamakCore/App/Scenes/WindowGroup.swift b/Sources/TokamakCore/App/Scenes/WindowGroup.swift index 44751717d..a08f83240 100644 --- a/Sources/TokamakCore/App/Scenes/WindowGroup.swift +++ b/Sources/TokamakCore/App/Scenes/WindowGroup.swift @@ -15,6 +15,11 @@ // Created by Carson Katri on 7/16/20. // +@_spi(TokamakCore) +public struct _WindowGroupTitle: _PrimitiveView { + public let title: Text? +} + public struct WindowGroup: Scene, TitledScene where Content: View { public let id: String public let title: Text? @@ -77,6 +82,9 @@ public struct WindowGroup: Scene, TitledScene where Content: View { // } public func _visitChildren(_ visitor: V) where V: SceneVisitor { - visitor.visit(content) + visitor.visit(Group { + _WindowGroupTitle(title: self.title) + content + }) } } diff --git a/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift b/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift index 49f98bb04..70faa6bb7 100644 --- a/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift +++ b/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift @@ -29,7 +29,7 @@ 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), + origin: .init(parent: .zero, origin: .zero), dimensions: .init(size: .zero, alignmentGuides: [:]), proposal: .unspecified ) diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index ac3ece280..953a9c0fb 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -423,7 +423,8 @@ public extension FiberReconciler { environment: .init(rootEnvironment), traits: .init(), preferenceStore: preferences - ) + ), + preferenceStore: preferences ?? .init() ) if let preferenceStore = outputs.preferenceStore { preferences = preferenceStore diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index 0fc52e5e3..ea2e148cf 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -41,7 +41,6 @@ public final class FiberReconciler { private var sceneSizeCancellable: AnyCancellable? - private var isReconciling = false /// The identifiers for each `Fiber` that changed state during the last run loop. /// /// The reconciler loop starts at the root of the `View` hierarchy @@ -49,7 +48,6 @@ public final class FiberReconciler { /// To help mitigate performance issues related to this, we only perform reconcile /// checks when we reach a changed `Fiber`. private var changedFibers = Set() - public var afterReconcileActions = [() -> ()]() struct RootView: View { let content: Content @@ -59,7 +57,6 @@ public final class FiberReconciler { var environment = reconciler.renderer.defaultEnvironment environment.measureText = reconciler.renderer.measureText environment.measureImage = reconciler.renderer.measureImage - environment.afterReconcile = reconciler.afterReconcile return environment } @@ -69,6 +66,10 @@ public final class FiberReconciler { .environmentValues(environment) } } + + static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + .init(inputs: inputs, preferenceStore: inputs.preferenceStore ?? .init()) + } } /// The `Layout` container for the root of a `View` hierarchy. @@ -140,7 +141,6 @@ public final class FiberReconciler { var environment = renderer.defaultEnvironment environment.measureText = renderer.measureText environment.measureImage = renderer.measureImage - environment.afterReconcile = afterReconcile var app = app current = .init( &app, @@ -215,15 +215,6 @@ public final class FiberReconciler { } } - func afterReconcile(_ action: @escaping () -> ()) { - guard isReconciling == true - else { - action() - return - } - afterReconcileActions.append(action) - } - /// Called by any `Fiber` that experiences a state change. /// /// Reconciliation only runs after every change during the current run loop has been performed. @@ -243,7 +234,6 @@ public final class FiberReconciler { /// /// A `reconcile()` call is queued from `fiberChanged` once per run loop. func reconcile() { - isReconciling = true let changedFibers = changedFibers self.changedFibers.removeAll() // Create a list of mutations. @@ -270,21 +260,8 @@ public final class FiberReconciler { self.alternate = current current = alternate - isReconciling = false - - for action in afterReconcileActions { - action() + if let preferences = current.preferences { + renderer.preferencesChanged(preferences) } } } - -public extension EnvironmentValues { - private enum AfterReconcileKey: EnvironmentKey { - static let defaultValue: (@escaping () -> ()) -> () = { _ in } - } - - var afterReconcile: (@escaping () -> ()) -> () { - get { self[AfterReconcileKey.self] } - set { self[AfterReconcileKey.self] = newValue } - } -} diff --git a/Sources/TokamakCore/Fiber/FiberRenderer.swift b/Sources/TokamakCore/Fiber/FiberRenderer.swift index 51c6e070b..d9108d4dc 100644 --- a/Sources/TokamakCore/Fiber/FiberRenderer.swift +++ b/Sources/TokamakCore/Fiber/FiberRenderer.swift @@ -88,6 +88,9 @@ public protocol FiberRenderer { /// (in this case just `DuelOfTheStates` as both properties were on it), /// and reconcile after all changes have been collected. func schedule(_ action: @escaping () -> ()) + + /// Called by the reconciler when the preferences of the topmost `Fiber` changed. + func preferencesChanged(_ preferenceStore: _PreferenceStore) } public extension FiberRenderer { @@ -107,6 +110,8 @@ public extension FiberRenderer { } } + func preferencesChanged(_ preferenceStore: _PreferenceStore) {} + @discardableResult @_disfavoredOverload func render(_ view: V) -> FiberReconciler { diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift index 2f1298775..7661e09bb 100644 --- a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift +++ b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift @@ -22,15 +22,20 @@ public struct LayoutSubviews: Equatable, RandomAccessCollection { public var layoutDirection: LayoutDirection var storage: [LayoutSubview] - init(layoutDirection: LayoutDirection, storage: [LayoutSubview]) { + @_spi(TokamakCore) + public var globalOrigin: CGPoint + + init(layoutDirection: LayoutDirection, storage: [LayoutSubview], globalOrigin: CGPoint) { self.layoutDirection = layoutDirection self.storage = storage + self.globalOrigin = globalOrigin } init(_ node: FiberReconciler.Fiber) { self.init( layoutDirection: node.outputs.environment.environment.layoutDirection, - storage: [] + storage: [], + globalOrigin: node.geometry?.origin.globalOrigin ?? .zero ) } @@ -53,7 +58,11 @@ public struct LayoutSubviews: Equatable, RandomAccessCollection { } public subscript(bounds: Range) -> LayoutSubviews { - .init(layoutDirection: layoutDirection, storage: .init(storage[bounds])) + .init( + layoutDirection: layoutDirection, + storage: .init(storage[bounds]), + globalOrigin: globalOrigin + ) } public subscript(indices: S) -> LayoutSubviews where S: Sequence, S.Element == Int { @@ -61,7 +70,8 @@ public struct LayoutSubviews: Equatable, RandomAccessCollection { layoutDirection: layoutDirection, storage: storage.enumerated() .filter { indices.contains($0.offset) } - .map(\.element) + .map(\.element), + globalOrigin: globalOrigin ) } } @@ -165,10 +175,13 @@ public struct LayoutSubview: Equatable { 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) - )), + origin: .init( + parent: fiber.elementParent?.geometry?.origin.globalOrigin ?? .zero, + origin: .init( + x: position.x - (dimensions.width * anchor.x), + y: position.y - (dimensions.height * anchor.y) + ) + ), dimensions: dimensions, proposal: proposal ) @@ -176,6 +189,10 @@ public struct LayoutSubview: Equatable { if geometry != fiber.alternate?.geometry { caches.mutations.append(.layout(element: element, geometry: geometry)) } + caches.layoutSubviews[ + ObjectIdentifier(fiber), + default: .init(fiber) + ].globalOrigin = geometry.origin.globalOrigin // Update ours and our alternate's geometry fiber.geometry = geometry fiber.alternate?.geometry = geometry diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index c2efdef4b..e4a481cf6 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -251,7 +251,7 @@ struct ReconcilePass: FiberReconcilerPass { previous: element, newContent: newContent, geometry: node.fiber?.geometry ?? .init( - origin: .init(origin: .zero), + origin: .init(parent: .zero, origin: .zero), dimensions: .init(size: .zero, alignmentGuides: [:]), proposal: .unspecified ) diff --git a/Sources/TokamakCore/Fiber/ViewGeometry.swift b/Sources/TokamakCore/Fiber/ViewGeometry.swift index 845aa30b8..c4cddda28 100644 --- a/Sources/TokamakCore/Fiber/ViewGeometry.swift +++ b/Sources/TokamakCore/Fiber/ViewGeometry.swift @@ -29,6 +29,9 @@ public struct ViewGeometry: Equatable { /// The position of the `View` relative to its parent. public struct ViewOrigin: Equatable { + @_spi(TokamakCore) + public let parent: CGPoint + @_spi(TokamakCore) public let origin: CGPoint @@ -36,6 +39,10 @@ public struct ViewOrigin: Equatable { public var x: CGFloat { origin.x } @_spi(TokamakCore) public var y: CGFloat { origin.y } + + public var globalOrigin: CGPoint { + parent.offset(by: origin) + } } public struct ViewDimensions: Equatable { diff --git a/Sources/TokamakCore/Preferences/PreferenceKey.swift b/Sources/TokamakCore/Preferences/PreferenceKey.swift index 913be61d0..4c8855b5b 100644 --- a/Sources/TokamakCore/Preferences/PreferenceKey.swift +++ b/Sources/TokamakCore/Preferences/PreferenceKey.swift @@ -118,6 +118,19 @@ public final class _PreferenceStore: CustomDebugStringConvertible { _PreferenceValue(storage: previousValues[ObjectIdentifier(key)] ?? .init(key)) } + /// Returns the new value for `Key`, or `nil` if the value did not change. + public func newValue(forKey key: Key.Type = Key.self) -> Key.Value? + where Key: PreferenceKey, Key.Value: Equatable + { + let value = value(forKey: key).value + let previousValue = previousValue(forKey: key).value + if value != previousValue { + return value + } else { + return nil + } + } + public func insert(_ value: Key.Value, forKey key: Key.Type = Key.self) where Key: PreferenceKey { diff --git a/Sources/TokamakDOM/DOMFiberRenderer.swift b/Sources/TokamakDOM/DOMFiberRenderer.swift index 5fff280be..1b3869a22 100644 --- a/Sources/TokamakDOM/DOMFiberRenderer.swift +++ b/Sources/TokamakDOM/DOMFiberRenderer.swift @@ -225,9 +225,7 @@ public struct DOMFiberRenderer: FiberRenderer { src: bundle? .path(forResource: name, ofType: nil) ?? name ) { naturalSize in - environment.afterReconcile { - image._intrinsicSize = naturalSize - } + image._intrinsicSize = naturalSize } return .zero case let .resizable(.named(name, bundle: bundle), _, _): @@ -239,9 +237,7 @@ public struct DOMFiberRenderer: FiberRenderer { src: bundle? .path(forResource: name, ofType: nil) ?? name ) { naturalSize in - environment.afterReconcile { - image._intrinsicSize = naturalSize - } + image._intrinsicSize = naturalSize } return .zero } @@ -323,6 +319,38 @@ public struct DOMFiberRenderer: FiberRenderer { } } + final class Head { + let head = document.head.object! + var metaTags = [JSObject]() + var title: JSObject? + } + + private let head = Head() + public func preferencesChanged(_ preferenceStore: _PreferenceStore) { + if let newMetaTags = preferenceStore.newValue(forKey: HTMLMetaPreferenceKey.self) { + for oldTag in head.metaTags { + _ = head.head.removeChild!(oldTag) + } + head.metaTags = newMetaTags.map { + let template = document.createElement!("template").object! + template.innerHTML = .string($0.outerHTML()) + let meta = template.content.firstChild.object! + _ = head.head.appendChild!(meta) + return meta + } + } + if let newTitle = preferenceStore.newValue(forKey: HTMLTitlePreferenceKey.self) { + if let title = head.title { + title.innerHTML = .string(newTitle) + } else { + let node = document.createElement!("title").object! + _ = head.head.appendChild!(node) + node.innerHTML = .string(newTitle) + head.title = node + } + } + } + private let scheduler = JSScheduler() public func schedule(_ action: @escaping () -> ()) { scheduler.schedule(options: nil, action) diff --git a/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift b/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift index 5128d3dac..592bffeee 100644 --- a/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift +++ b/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -@_spi(TokamakCore) import TokamakCore +import Foundation +@_spi(TokamakCore) +import TokamakCore extension _BackgroundStyleModifier: DOMViewModifier { public var isOrderDependent: Bool { true } @@ -95,12 +97,95 @@ extension _BackgroundStyleModifier: HTMLConvertible, } else { return { $0 - .visit(_BackgroundLayout( - content: content, - background: Rectangle().fill(style), - alignment: .center - )) + .visit( + _BackgroundStyleLayout( + style: style, + backgroundLayout: _BackgroundLayout( + content: content, + background: _ShapeView(shape: Rectangle(), style: style), + alignment: .center + ) + ) + ) } } } } + +struct _BackgroundStyleLayout< + Content: View, + Style: ShapeStyle +>: _PrimitiveView, HTMLConvertible, Layout { + let style: Style + let backgroundLayout: _BackgroundLayout> + + @Environment(\.self) + var environment + @State + private var fillsScene = false + + var tag: String { "div" } + func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + [:] + } + + func _visitChildren(_ visitor: V) where V: ViewVisitor { + visitor.visit(backgroundLayout.background) + visitor.visit(backgroundLayout.content) + // If the background reaches the top of the scene, apply a "theme-color". + // This matches SwiftUI's behavior where a `_BackgroundStyleModifier` that reaches the top + // will extend into the safe area. + if fillsScene { + var shape = _ShapeStyle_Shape( + for: .resolveStyle(levels: 0..<1), + in: environment, + role: .fill + ) + style._apply(to: &shape) + guard let style = shape.result.resolvedStyle(on: shape, in: environment), + let color = style.color(at: 0) + else { return } + visitor.visit(HTMLMeta( + name: "theme-color", + content: color.cssValue(environment) + )) + } + } + + typealias Cache = _BackgroundLayout>.Cache + + func makeCache(subviews: Subviews) -> Cache { + backgroundLayout.makeCache(subviews: subviews) + } + + func spacing(subviews: LayoutSubviews, cache: inout Cache) -> ViewSpacing { + backgroundLayout.spacing(subviews: subviews, cache: &cache) + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + backgroundLayout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + // If the minY == 0, we are touching the top of the scene. + let fillsScene = subviews.globalOrigin.y == 0 + if fillsScene != self.fillsScene { + self.fillsScene = fillsScene + } + return backgroundLayout.placeSubviews( + in: bounds, + proposal: proposal, + subviews: subviews, + cache: &cache + ) + } +} diff --git a/Sources/TokamakStaticHTML/Scenes/WindowGroup.swift b/Sources/TokamakStaticHTML/Scenes/WindowGroup.swift index d8cc1800d..87c6f231b 100644 --- a/Sources/TokamakStaticHTML/Scenes/WindowGroup.swift +++ b/Sources/TokamakStaticHTML/Scenes/WindowGroup.swift @@ -15,10 +15,26 @@ // Created by Carson Katri on 7/19/20. // -import TokamakCore +@_spi(TokamakCore) import TokamakCore extension WindowGroup: SceneDeferredToRenderer { public var deferredBody: AnyView { AnyView(content) } } + +extension _WindowGroupTitle: HTMLConvertible { + public var tag: String { "div" } + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + guard !useDynamicLayout else { return [:] } + return ["style": "position: absolute; width: 0; height: 0; top: 0; left: 0;"] + } + + public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V: ViewVisitor { + { + if let title = self.title { + $0.visit(HTMLTitle(_TextProxy(title).rawText)) + } + } + } +} diff --git a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift index fa50df5a8..afb6cf43d 100644 --- a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift +++ b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift @@ -237,24 +237,40 @@ public struct StaticHTMLFiberRenderer: FiberRenderer { public func render(_ app: A) -> String { _ = FiberReconciler(self, app) - return """ - - - \(rootElement.description) - - """ + return renderedHTML() } public func render(_ view: V) -> String { _ = FiberReconciler(self, view) - return """ + return renderedHTML() + } + + private func renderedHTML() -> String { + """ + + \(head.title != nil ? "\(head.title!)" : "") + \(head.metaTags.joined(separator: "\n")) + \(rootElement.description) """ } + private final class Head { + var metaTags = [String]() + var title: String? + } + + private let head = Head() + public func preferencesChanged(_ preferenceStore: _PreferenceStore) { + head.metaTags = preferenceStore.value(forKey: HTMLMetaPreferenceKey.self).value.map { + $0.outerHTML() + } + head.title = preferenceStore.value(forKey: HTMLTitlePreferenceKey.self).value + } + public func schedule(_ action: @escaping () -> ()) { action() } diff --git a/Sources/TokamakStaticHTML/StaticHTMLRenderer.swift b/Sources/TokamakStaticHTML/StaticHTMLRenderer.swift index 1164b07c1..a7d27b250 100644 --- a/Sources/TokamakStaticHTML/StaticHTMLRenderer.swift +++ b/Sources/TokamakStaticHTML/StaticHTMLRenderer.swift @@ -58,7 +58,7 @@ struct HTMLBody: AnyHTML { ] } -extension HTMLMeta.MetaTag { +public extension HTMLMeta.MetaTag { func outerHTML() -> String { switch self { case let .charset(charset): diff --git a/Tests/TokamakLayoutTests/FrameTests.swift b/Tests/TokamakLayoutTests/FrameTests.swift index 7427cd46c..f18434e81 100644 --- a/Tests/TokamakLayoutTests/FrameTests.swift +++ b/Tests/TokamakLayoutTests/FrameTests.swift @@ -98,17 +98,17 @@ final class FrameTests: XCTestCase { for (nativeVertical, tokamakVertical) in SwiftUI.VerticalAlignment.allCases { await compare(size: .init(width: 500, height: 500)) { SwiftUI.Rectangle() - .fill(SwiftUI.Color(white: 0)) + .fill(Color(white: 0)) .frame(width: 100, height: 100) .frame( maxWidth: .infinity, maxHeight: .infinity, alignment: .init(horizontal: nativeHorizontal, vertical: nativeVertical) ) - .background(Color(white: 127 / 255)) + .background(Rectangle().fill(Color(white: 127 / 255))) } to: { TokamakStaticHTML.Rectangle() - .fill(TokamakStaticHTML.Color(white: 0)) + .fill(Color(white: 0)) .frame(width: 100, height: 100) .frame( maxWidth: .infinity, @@ -118,7 +118,7 @@ final class FrameTests: XCTestCase { vertical: tokamakVertical ) ) - .background(Color(white: 127 / 255)) + .background(Rectangle().fill(Color(white: 127 / 255))) } } }