This commit is contained in:
Carson Katri 2023-02-26 18:46:13 -08:00 committed by GitHub
commit a59b9c6c67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 241 additions and 68 deletions

View File

@ -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<Content>: Scene, TitledScene where Content: View {
public let id: String
public let title: Text?
@ -77,6 +82,9 @@ public struct WindowGroup<Content>: Scene, TitledScene where Content: View {
// }
public func _visitChildren<V>(_ visitor: V) where V: SceneVisitor {
visitor.visit(content)
visitor.visit(Group {
_WindowGroupTitle(title: self.title)
content
})
}
}

View File

@ -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
)

View File

@ -423,7 +423,8 @@ public extension FiberReconciler {
environment: .init(rootEnvironment),
traits: .init(),
preferenceStore: preferences
)
),
preferenceStore: preferences ?? .init()
)
if let preferenceStore = outputs.preferenceStore {
preferences = preferenceStore

View File

@ -41,7 +41,6 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
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<Renderer: FiberRenderer> {
/// To help mitigate performance issues related to this, we only perform reconcile
/// checks when we reach a changed `Fiber`.
private var changedFibers = Set<ObjectIdentifier>()
public var afterReconcileActions = [() -> ()]()
struct RootView<Content: View>: View {
let content: Content
@ -59,7 +57,6 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
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<Renderer: FiberRenderer> {
.environmentValues(environment)
}
}
static func _makeView(_ inputs: ViewInputs<Self>) -> 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<Renderer: FiberRenderer> {
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<Renderer: FiberRenderer> {
}
}
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<Renderer: FiberRenderer> {
///
/// 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<Renderer: FiberRenderer> {
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 }
}
}

View File

@ -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<V: View>(_ view: V) -> FiberReconciler<Self> {

View File

@ -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<R: FiberRenderer>(_ node: FiberReconciler<R>.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<Int>) -> LayoutSubviews {
.init(layoutDirection: layoutDirection, storage: .init(storage[bounds]))
.init(
layoutDirection: layoutDirection,
storage: .init(storage[bounds]),
globalOrigin: globalOrigin
)
}
public subscript<S>(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

View File

@ -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
)

View File

@ -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 {

View File

@ -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<Key>(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<Key>(_ value: Key.Value, forKey key: Key.Type = Key.self)
where Key: PreferenceKey
{

View File

@ -230,9 +230,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), _, _):
@ -244,9 +242,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
}
@ -328,6 +324,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)

View File

@ -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<Content, _ShapeView<Rectangle, Style>>
@Environment(\.self)
var environment
@State
private var fillsScene = false
var tag: String { "div" }
func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] {
[:]
}
func _visitChildren<V>(_ 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<Content, _ShapeView<Rectangle, Style>>.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
)
}
}

View File

@ -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<V>(useDynamicLayout: Bool) -> ((V) -> ())? where V: ViewVisitor {
{
if let title = self.title {
$0.visit(HTMLTitle(_TextProxy(title).rawText))
}
}
}
}

View File

@ -237,22 +237,38 @@ public struct StaticHTMLFiberRenderer: FiberRenderer {
public func render<A: App>(_ app: A) -> String {
_ = FiberReconciler(self, app)
return """
return renderedHTML()
}
public func render<V: View>(_ view: V) -> String {
_ = FiberReconciler(self, view)
return renderedHTML()
}
private func renderedHTML() -> String {
"""
<!doctype html>
<head>
\(head.title != nil ? "<title>\(head.title!)</title>" : "")
\(head.metaTags.joined(separator: "\n"))
</head>
<html>
\(rootElement.description)
</html>
"""
}
public func render<V: View>(_ view: V) -> String {
_ = FiberReconciler(self, view)
return """
<!doctype html>
<html>
\(rootElement.description)
</html>
"""
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 () -> ()) {

View File

@ -58,7 +58,7 @@ struct HTMLBody: AnyHTML {
]
}
extension HTMLMeta.MetaTag {
public extension HTMLMeta.MetaTag {
func outerHTML() -> String {
switch self {
case let .charset(charset):

View File

@ -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)))
}
}
}