Add ViewTree, onHover modifier, and various other debug changes

This commit is contained in:
Carson Katri 2020-08-07 11:05:41 -04:00
parent 7ab7178004
commit bc5470d05a
21 changed files with 368 additions and 9 deletions

View File

@ -19,6 +19,15 @@
"version": null
}
},
{
"package": "pure-swift-json",
"repositoryURL": "https://github.com/fabianfett/pure-swift-json.git",
"state": {
"branch": null,
"revision": "5dc8ec1d857f3b56e3a718a01165ae80c7677d48",
"version": "0.4.0"
}
},
{
"package": "Runtime",
"repositoryURL": "https://github.com/MaxDesiatov/Runtime.git",

View File

@ -40,6 +40,7 @@ let package = Package(
.package(url: "https://github.com/kateinoigakukun/JavaScriptKit.git", .revision("c90e82f")),
.package(url: "https://github.com/MaxDesiatov/Runtime.git", .branch("wasi-build")),
.package(url: "https://github.com/MaxDesiatov/OpenCombine.git", .branch("observable-object")),
.package(url: "https://github.com/fabianfett/pure-swift-json.git", from: "0.4.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define
@ -66,7 +67,13 @@ let package = Package(
),
.target(
name: "TokamakDOM",
dependencies: ["CombineShim", "JavaScriptKit", "TokamakCore", "TokamakStaticHTML"]
dependencies: [
"CombineShim",
"JavaScriptKit",
"TokamakCore",
"TokamakStaticHTML",
.product(name: "PureSwiftJSON", package: "pure-swift-json"),
]
),
.target(
name: "TokamakShim",

View File

@ -22,8 +22,8 @@ public struct _ViewModifier_Content<Modifier>: View where Modifier: ViewModifier
public let modifier: Modifier
public let view: AnyView
public var body: Never {
neverBody("_ViewModifier_Content")
public var body: AnyView {
view
}
}
@ -38,3 +38,17 @@ extension ViewModifier where Body == Never {
fatalError("\(self) is a primitive `ViewModifier`, you're not supposed to run `body(content:)`")
}
}
public protocol ViewModifierDeferredToRenderer {
func deferredBody<Content>(content: Content) -> AnyView where Content: View
}
public struct _ViewModifierProxy<Modifier: ViewModifier> {
let subject: Modifier
public init(_ subject: Modifier) { self.subject = subject }
public func body<Content: View>(content: Content) -> Modifier.Body {
subject.body(content: .init(modifier: subject, view: AnyView(content)))
}
}

View File

@ -0,0 +1,33 @@
// Copyright 2020 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 8/6/20.
//
extension View {
public func onHover(perform action: @escaping (Bool) -> ()) -> some View {
modifier(_HoverRegionModifier(action))
}
}
public struct _HoverRegionModifier: ViewModifier {
public let callback: (Bool) -> ()
public init(_ callback: @escaping (Bool) -> ()) {
self.callback = callback
}
public func body(content: Content) -> some View {
content
}
}

View File

@ -28,10 +28,18 @@ final class MountedApp<R: Renderer>: MountedCompositeElement<R> {
let child: MountedElement<R> = mountChild(childBody)
mountedChildren = [child]
child.mount(with: reconciler)
#if DEBUG
reconciler.debugTree[ObjectIdentifier(child)] = child.debugNode(parent: self)
#endif
}
override func unmount(with reconciler: StackReconciler<R>) {
mountedChildren.forEach { $0.unmount(with: reconciler) }
mountedChildren.forEach {
$0.unmount(with: reconciler)
#if DEBUG
reconciler.debugTree[ObjectIdentifier($0)] = nil
#endif
}
}
private func mountChild(_ childBody: _AnyScene) -> MountedElement<R> {
@ -56,4 +64,15 @@ final class MountedApp<R: Renderer>: MountedCompositeElement<R> {
mountChild: { mountChild($0) }
)
}
override func debugNode(parent: MountedElement<R>? = nil) -> ViewTree<R>.Node {
// swiftlint:disable:next force_try
let info = try! typeInfo(of: app.type)
return .init(type: app.type,
isPrimitive: false,
isHost: false,
dynamicProperties: info.properties.filter { $0.type is DynamicProperty.Type }.map(\.name),
object: self,
parent: parent)
}
}

View File

@ -29,6 +29,9 @@ final class MountedCompositeView<R: Renderer>: MountedCompositeElement<R> {
let child: MountedElement<R> = childBody.makeMountedView(parentTarget, environmentValues)
mountedChildren = [child]
child.mount(with: reconciler)
#if DEBUG
reconciler.debugTree[ObjectIdentifier(child)] = child.debugNode(parent: self)
#endif
// `_TargetRef` is a composite view, so it's enough to check for it only here
if var targetRef = view.view as? TargetRefType {
@ -47,7 +50,12 @@ final class MountedCompositeView<R: Renderer>: MountedCompositeElement<R> {
}
override func unmount(with reconciler: StackReconciler<R>) {
mountedChildren.forEach { $0.unmount(with: reconciler) }
mountedChildren.forEach {
$0.unmount(with: reconciler)
#if DEBUG
reconciler.debugTree[ObjectIdentifier($0)] = nil
#endif
}
if let appearanceAction = view.view as? AppearanceActionType {
appearanceAction.disappear?()
@ -67,4 +75,15 @@ final class MountedCompositeView<R: Renderer>: MountedCompositeElement<R> {
mountChild: { $0.makeMountedView(parentTarget, environmentValues) }
)
}
override func debugNode(parent: MountedElement<R>? = nil) -> ViewTree<R>.Node {
// swiftlint:disable:next force_try
let info = try! typeInfo(of: view.type)
return .init(type: view.type,
isPrimitive: view.bodyType is Never.Type,
isHost: false,
dynamicProperties: info.properties.filter { $0.type is DynamicProperty.Type }.map(\.name),
object: self,
parent: parent)
}
}

View File

@ -33,7 +33,7 @@ enum MountedElementKind {
}
public class MountedElement<R: Renderer> {
private var element: MountedElementKind
var element: MountedElementKind
public internal(set) var app: _AnyApp {
get {
@ -132,6 +132,10 @@ public class MountedElement<R: Renderer> {
func update(with reconciler: StackReconciler<R>) {
fatalError("implement \(#function) in subclass")
}
func debugNode(parent: MountedElement<R>? = nil) -> ViewTree<R>.Node {
fatalError("implement \(#function) in subclass")
}
}
extension TypeInfo {

View File

@ -21,4 +21,12 @@ final class MountedEmptyView<R: Renderer>: MountedElement<R> {
override func unmount(with reconciler: StackReconciler<R>) {}
override func update(with reconciler: StackReconciler<R>) {}
override func debugNode(parent: MountedElement<R>? = nil) -> ViewTree<R>.Node {
.init(type: EmptyView.self,
isPrimitive: true,
isHost: false,
object: self,
parent: parent)
}
}

View File

@ -48,7 +48,12 @@ public final class MountedHostView<R: Renderer>: MountedElement<R> {
mountedChildren = view.children.map {
$0.makeMountedView(target, environmentValues)
}
mountedChildren.forEach { $0.mount(with: reconciler) }
mountedChildren.forEach {
$0.mount(with: reconciler)
#if DEBUG
reconciler.debugTree[ObjectIdentifier($0)] = $0.debugNode(parent: self)
#endif
}
}
override func unmount(with reconciler: StackReconciler<R>) {
@ -59,7 +64,12 @@ public final class MountedHostView<R: Renderer>: MountedElement<R> {
from: parentTarget,
with: self
) {
self.mountedChildren.forEach { $0.unmount(with: reconciler) }
self.mountedChildren.forEach {
$0.unmount(with: reconciler)
#if DEBUG
reconciler.debugTree[ObjectIdentifier($0)] = nil
#endif
}
}
}
@ -132,4 +142,13 @@ public final class MountedHostView<R: Renderer>: MountedElement<R> {
case (true, true): ()
}
}
override func debugNode(parent: MountedElement<R>? = nil) -> ViewTree<R>.Node {
.init(type: view.type,
isPrimitive: view.bodyType is Never.Type,
isHost: true,
target: target,
object: self,
parent: parent)
}
}

View File

@ -35,10 +35,18 @@ final class MountedScene<R: Renderer>: MountedCompositeElement<R> {
let child: MountedElement<R> = childBody.makeMountedElement(parentTarget, environmentValues)
mountedChildren = [child]
child.mount(with: reconciler)
#if DEBUG
reconciler.debugTree[ObjectIdentifier(child)] = child.debugNode(parent: self)
#endif
}
override func unmount(with reconciler: StackReconciler<R>) {
mountedChildren.forEach { $0.unmount(with: reconciler) }
mountedChildren.forEach {
$0.unmount(with: reconciler)
#if DEBUG
reconciler.debugTree[ObjectIdentifier($0)] = nil
#endif
}
}
override func update(with reconciler: StackReconciler<R>) {
@ -59,6 +67,17 @@ final class MountedScene<R: Renderer>: MountedCompositeElement<R> {
mountChild: { $0.makeMountedElement(parentTarget, environmentValues) }
)
}
override func debugNode(parent: MountedElement<R>? = nil) -> ViewTree<R>.Node {
// swiftlint:disable:next force_try
let info = try! typeInfo(of: scene.type)
return .init(type: scene.type,
isPrimitive: scene.bodyType is Never.Type,
isHost: false,
dynamicProperties: info.properties.filter { $0.type is DynamicProperty.Type }.map(\.name),
object: self,
parent: parent)
}
}
extension _AnyScene.BodyResult {

View File

@ -68,4 +68,14 @@ public protocol Renderer: AnyObject {
with host: MountedHost,
completion: @escaping () -> ()
)
func debugTree(
_ tree: Set<ViewTree<Self>.Node>,
context: ViewTree<Self>.Context
)
}
extension Renderer {
public func debugTree(_ tree: Set<ViewTree<Self>.Node>,
context: ViewTree<Self>.Context) {}
}

View File

@ -63,6 +63,11 @@ public final class StackReconciler<R: Renderer> {
*/
private let scheduler: (@escaping () -> ()) -> ()
#if DEBUG
typealias Tree = ViewTree<R>
var debugTree: [ObjectIdentifier: Tree.Node]
#endif
public init<V: View>(
view: V,
target: R.TargetType,
@ -76,7 +81,15 @@ public final class StackReconciler<R: Renderer> {
rootElement = AnyView(view).makeMountedView(target, environment)
#if DEBUG
debugTree = [ObjectIdentifier(rootElement): rootElement.debugNode(parent: nil)]
#endif
rootElement.mount(with: self)
#if DEBUG
renderer.debugTree(Set(debugTree.values), context: .firstRender)
#endif
}
public init<A: App>(
@ -92,11 +105,19 @@ public final class StackReconciler<R: Renderer> {
rootElement = MountedApp(app, target, environment)
#if DEBUG
debugTree = [ObjectIdentifier(rootElement): rootElement.debugNode(parent: nil)]
#endif
rootElement.mount(with: self)
if let mountedApp = rootElement as? MountedApp<R> {
setupSubscription(for: app._phasePublisher, to: \.scenePhase, of: mountedApp)
setupSubscription(for: app._colorSchemePublisher, to: \.colorScheme, of: mountedApp)
}
#if DEBUG
renderer.debugTree(Set(debugTree.values), context: .firstRender)
#endif
}
private func queueStateUpdate(
@ -118,10 +139,20 @@ public final class StackReconciler<R: Renderer> {
}
private func updateStateAndReconcile() {
#if DEBUG
let preValues = Set(debugTree.values)
#endif
for mountedView in queuedRerenders {
mountedView.update(with: self)
}
#if DEBUG
// Only send the difference for performance reasons
renderer?.debugTree(Set(debugTree.values).subtracting(preValues),
context: .update)
#endif
queuedRerenders.removeAll()
}

View File

@ -17,4 +17,10 @@
public protocol Target: AnyObject {
var view: AnyView { get set }
/// Provide a way to identify this Target from the debug tree.
var debugIdentifier: String? { get }
}
extension Target {
public var debugIdentifier: String? { nil }
}

View File

@ -0,0 +1,66 @@
// Copyright 2018-2020 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 8/5/20.
//
import Runtime
extension ObjectIdentifier: Encodable {
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(hashValue)
}
}
public final class ViewTree<R: Renderer> {
public enum Context {
case firstRender
case update
}
public struct Node: Encodable, Hashable {
let type: String
let isPrimitive: Bool
let isHost: Bool
let dynamicProperties: [String]
let target: String?
var id: ObjectIdentifier
var parent: ObjectIdentifier?
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}
init(type: Any.Type,
isPrimitive: Bool,
isHost: Bool,
dynamicProperties: [String] = [],
target: R.TargetType? = nil,
object: MountedElement<R>,
parent: MountedElement<R>? = nil) {
self.type = "\(type)"
id = ObjectIdentifier(object)
self.isPrimitive = isPrimitive
self.isHost = isHost
self.dynamicProperties = dynamicProperties
self.target = target?.debugIdentifier
if let parent = parent {
self.parent = ObjectIdentifier(parent)
} else {
self.parent = nil
}
}
}
}

View File

@ -42,10 +42,15 @@ final class DOMNode: Target {
private var listeners: [String: JSClosure]
var view: AnyView
let debugIdentifier: String?
init<V: View>(_ view: V, _ ref: JSObjectRef, _ listeners: [String: Listener] = [:]) {
self.ref = ref
self.listeners = [:]
self.view = AnyView(view)
debugIdentifier = ref.id.string
reinstall(listeners)
}
@ -53,6 +58,8 @@ final class DOMNode: Target {
self.ref = ref
view = AnyView(EmptyView())
listeners = [:]
debugIdentifier = ref.id.string
}
/// Removes all existing event listeners on this DOM node and install new ones from

View File

@ -16,6 +16,7 @@
//
import JavaScriptKit
import PureSwiftJSON
import TokamakCore
import TokamakStaticHTML
@ -126,6 +127,8 @@ final class DOMRenderer: Renderer {
lastChild.style.object!.height = "100%"
}
lastChild.id = "\(ObjectIdentifier(host).hashValue)".jsValue()
if let dynamicHTML = anyHTML as? AnyDynamicHTML {
return DOMNode(host.view, lastChild, dynamicHTML.listeners)
} else {
@ -153,4 +156,23 @@ final class DOMRenderer: Renderer {
_ = parent.ref.removeChild!(target.ref)
}
public func debugTree(
_ tree: Set<ViewTree<DOMRenderer>.Node>,
context: ViewTree<DOMRenderer>.Context
) {
if tree.count > 0,
let json = try? PSJSONEncoder().encode(tree) {
switch context {
case .firstRender:
JSObjectRef.global.window.object!._tokamak_debug_tree = json.jsValue()
case .update:
let Object = JSObjectRef.global.Object.function!
let details = Object.new()
details.detail = json.jsValue()
let event = window.CustomEvent.function!.new("_tokamak_debug_tree_update", details)
_ = document.body.object!.dispatchEvent!(event)
}
}
}
}

View File

@ -0,0 +1,27 @@
// Copyright 2020 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 8/6/20.
//
import TokamakCore
extension _HoverRegionModifier: ViewModifierDeferredToRenderer {
public func deferredBody<Content>(content: Content) -> AnyView where Content: View {
AnyView(DynamicHTML("div", listeners: [
"mouseover": { _ in callback(true) },
"mouseout": { _ in callback(false) },
]) { _ViewModifierProxy(self).body(content: content) })
}
}

View File

@ -0,0 +1,18 @@
//
// File.swift
//
//
// Created by Carson Katri on 8/7/20.
//
import TokamakShim
struct HoverDemo: View {
@State private var isHovering = false
var body: some View {
Text(isHovering ? "Hovering" : "Not Hovering")
.onHover {
isHovering = $0
}
}
}

View File

@ -121,6 +121,9 @@ struct TokamakDemoView: View {
NavItem("Text", destination: TextDemo())
NavItem("TextField", destination: TextFieldDemo())
}
Section(header: Text("Actions")) {
NavItem("onHover", destination: HoverDemo())
}
Section(header: Text("Misc")) {
NavItem("Path", destination: PathDemo())
NavItem("Environment", destination: EnvironmentDemo().font(.system(size: 8)))

View File

@ -50,6 +50,8 @@ extension ModifiedContent: ViewDeferredToRenderer where Content: View {
content
})
}
} else if let viewModifier = modifier as? ViewModifierDeferredToRenderer {
return viewModifier.deferredBody(content: content)
} else {
return AnyView(content)
}

View File

@ -109,6 +109,22 @@ public let tokamakStyles = """
border-top-color: rgba(255, 255, 255, 0.25);
}
}
._tokamak-debug-hover {
position: relative;
}
._tokamak-debug-hover::after {
background-color: rgb(100, 178, 200);
opacity: 0.6;
position: absolute;
top: 0;
left: 0;
display: block;
bottom: 0;
right: 0;
content: '';
z-index: 999;
}
"""
public let rootNodeStyles = """