Add basic DOM renderer

This commit is contained in:
Max Desiatov 2020-06-17 00:58:10 +01:00
parent 5e85356a1c
commit 426bb999c5
No known key found for this signature in database
GPG Key ID: FE08EBF9CF58CBA2
23 changed files with 224 additions and 29 deletions

1
.swift-version Normal file
View File

@ -0,0 +1 @@
wasm-DEVELOPMENT-SNAPSHOT-2020-06-07-a

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"editor.formatOnSave": true,
"licenser.author": "Tokamak contributors"
}

5
.vscode/tasks.json vendored
View File

@ -12,6 +12,11 @@
"label": "swift test",
"type": "shell",
"command": "swift test"
},
{
"label": "carton dev",
"type": "shell",
"command": "carton dev --product TokamakDemo"
}
]
}

View File

@ -1,12 +1,21 @@
{
"object": {
"pins": [
{
"package": "JavaScriptKit",
"repositoryURL": "https://github.com/MaxDesiatov/JavaScriptKit.git",
"state": {
"branch": "1edcf70",
"revision": "1edcf707dcb06f50e980c6cac542f226361dc124",
"version": null
}
},
{
"package": "Runtime",
"repositoryURL": "https://github.com/MaxDesiatov/Runtime.git",
"state": {
"branch": "wasi-build",
"revision": "4ac000b019460c5eb5b9d7a8d7aaa1cb24076bf6",
"revision": "a617ead8a125a97e69d6100e4d27922006e82e0a",
"version": null
}
}

View File

@ -12,9 +12,13 @@ let package = Package(
products: [
// Products define the executables and libraries produced by a package,
// and make them visible to other packages.
.executable(
name: "TokamakDemo",
targets: ["TokamakDemo"]
),
.library(
name: "Tokamak",
targets: ["Tokamak"]
name: "TokamakDOM",
targets: ["TokamakDOM"]
),
.library(
name: "TokamakTestRenderer",
@ -24,6 +28,7 @@ let package = Package(
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
.package(url: "https://github.com/MaxDesiatov/JavaScriptKit.git", .revision("1edcf70")),
.package(url: "https://github.com/MaxDesiatov/Runtime.git", .branch("wasi-build")),
],
targets: [
@ -37,11 +42,11 @@ let package = Package(
),
.target(
name: "TokamakDemo",
dependencies: ["Tokamak"]
dependencies: ["JavaScriptKit", "Tokamak", "TokamakDOM"]
),
.target(
name: "TokamakDOM",
dependencies: ["Tokamak"]
dependencies: ["JavaScriptKit", "Tokamak"]
),
.target(
name: "TokamakTestRenderer",

View File

@ -2,7 +2,6 @@
// Created by Max Desiatov on 03/12/2018.
//
import Dispatch
import Runtime
final class MountedCompositeView<R: Renderer>: MountedView<R>, Hashable {

View File

@ -5,7 +5,6 @@
// Created by Max Desiatov on 28/11/2018.
//
import Dispatch
import Runtime
public final class StackReconciler<R: Renderer> {
@ -14,9 +13,16 @@ public final class StackReconciler<R: Renderer> {
public let rootTarget: R.TargetType
private let rootView: MountedView<R>
private(set) weak var renderer: R?
private let scheduler: (@escaping () -> ()) -> ()
public init<V: View>(view: V, target: R.TargetType, renderer: R) {
public init<V: View>(
view: V,
target: R.TargetType,
renderer: R,
scheduler: @escaping (@escaping () -> ()) -> ()
) {
self.renderer = renderer
self.scheduler = scheduler
rootTarget = target
rootView = view.makeMountedView(target)
@ -36,9 +42,7 @@ public final class StackReconciler<R: Renderer> {
guard scheduleReconcile else { return }
DispatchQueue.main.async {
self.updateStateAndReconcile()
}
scheduler { [weak self] in self?.updateStateAndReconcile() }
}
private func updateStateAndReconcile() {

View File

@ -8,7 +8,7 @@
open class Target {
public internal(set) var view: AnyView
public init<V: View>(view: V) {
public init<V: View>(_ view: V) {
self.view = AnyView(view)
}
}

View File

@ -44,8 +44,14 @@ public struct AnyView: View {
}
}
public func mapAnyView<T, V>(_ anyView: AnyView, transform: (V) -> T) -> T? {
guard let view = anyView.view as? V else { return nil }
return transform(view)
}
extension AnyView: ParentView {
var children: [AnyView] {
public var children: [AnyView] {
(view as? ParentView)?.children ?? []
}
}

View File

@ -4,7 +4,9 @@
public struct Button<Label>: View where Label: View {
let label: Label
let action: () -> ()
// FIXME: this should be internal
public let action: () -> ()
public init(action: @escaping () -> (), @ViewBuilder label: () -> Label) {
self.label = label()
@ -19,3 +21,13 @@ extension Button where Label == Text {
}
}
}
extension Button: ParentView {
public var children: [AnyView] {
(label as? GroupView)?.children ?? [AnyView(label)]
}
}
public func buttonLabel(_ button: Button<Text>) -> String {
button.label.content
}

View File

@ -25,7 +25,7 @@ public struct HStack<Content>: View where Content: View {
}
extension HStack: ParentView {
var children: [AnyView] {
public var children: [AnyView] {
(content as? GroupView)?.children ?? [AnyView(content)]
}
}

View File

@ -3,8 +3,7 @@
//
public struct Text: View {
// FIXME: should be internal
public let content: String
let content: String
public init(verbatim content: String) {
self.content = content
@ -14,3 +13,7 @@ public struct Text: View {
self.content = String(content)
}
}
public func textContent(_ text: Text) -> String {
text.content
}

View File

@ -5,7 +5,7 @@
public struct TupleView<T>: View {
public let value: T
let children: [AnyView]
public let children: [AnyView]
public init(_ value: T) {
self.value = value

View File

@ -17,7 +17,7 @@ public extension View where Body == Never {
}
/// A `View` type that renders with subviews, usually specified in the `Content` type argument
protocol ParentView {
public protocol ParentView {
var children: [AnyView] { get }
}

View File

@ -4,8 +4,13 @@
import Tokamak
extension Button: ViewDeferredToRenderer {
public typealias Button = Tokamak.Button
extension Button: ViewDeferredToRenderer where Label == Text {
public var deferredBody: AnyView {
AnyView(HTML(tag: "button"))
AnyView(HTML(
tag: "button",
listeners: ["click": { _ in action() }]
) { Text(buttonLabel(self)) })
}
}

View File

@ -2,18 +2,63 @@
// Created by Max Desiatov on 11/04/2020.
//
import JavaScriptKit
import Tokamak
public final class DOMNode: Target {}
public final class DOMNode: Target {
let ref: JSObjectRef
init<V: View>(_ view: V, _ ref: JSObjectRef) {
self.ref = ref
super.init(view)
}
}
public final class DOMRenderer: Renderer {
public private(set) var reconciler: StackReconciler<DOMRenderer>?
public func mountTarget(to parent: DOMNode, with view: MountedHost) -> DOMNode? {
nil
public init<V: View>(_ view: V, _ ref: JSObjectRef) {
reconciler = StackReconciler(view: view, target: DOMNode(view, ref), renderer: self) { closure in
let fn = JSFunctionRef.from { _ in
closure()
return .undefined
}
_ = JSObjectRef.global.setTimeout!(fn, 0)
}
}
public func mountTarget(to parent: DOMNode, with host: MountedHost) -> DOMNode? {
guard let (html, listeners) = mapAnyView(
host.view,
transform: { (html: AnyHTML) in (html.description, html.listeners) }
)
else { return nil }
parent.ref.innerHTML = JSValue(stringLiteral: html)
guard
let children = parent.ref.childNodes.object,
let length = children.length.number,
length > 0,
let firstChild = children[0].object
else { return nil }
for (event, listener) in listeners {
_ = firstChild.addEventListener!(event, JSFunctionRef.from {
listener($0[0].object!)
return .undefined
})
}
return DOMNode(host.view, firstChild)
}
public func update(target: DOMNode, with view: MountedHost) {}
public func unmount(target: DOMNode, from parent: DOMNode, with view: MountedHost, completion: @escaping () -> ()) {}
public func unmount(
target: DOMNode,
from parent: DOMNode,
with view: MountedHost,
completion: @escaping () -> ()
) {}
}

View File

@ -2,31 +2,53 @@
// Created by Max Desiatov on 11/04/2020.
//
import JavaScriptKit
import Tokamak
public struct HTML<Content>: View where Content: View {
public typealias Listener = (JSObjectRef) -> ()
protocol AnyHTML: CustomStringConvertible {
var listeners: [String: Listener] { get }
}
public struct HTML<Content>: View, AnyHTML where Content: View {
let tag: String
let attributes: [String: String]
let listeners: [String: Listener]
let content: Content
public init(
tag: String,
attributes: [String: String] = [:],
listeners: [String: Listener] = [:],
@ViewBuilder content: () -> Content
) {
self.tag = tag
self.attributes = attributes
self.listeners = listeners
self.content = content()
}
public var description: String {
"<\(tag) \(attributes.map { #"\#($0)="\#($1)""# }.joined(separator: " "))></\(tag)>"
}
}
extension HTML where Content == EmptyView {
public init(
tag: String,
attributes: [String: String] = [:]
attributes: [String: String] = [:],
listeners: [String: Listener] = [:]
) {
self.tag = tag
self.attributes = attributes
self.listeners = listeners
content = EmptyView()
}
}
extension HTML: ParentView {
public var children: [AnyView] {
[AnyView(content)]
}
}

View File

@ -0,0 +1,23 @@
// 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.
import Tokamak
public typealias Text = Tokamak.Text
extension Text: AnyHTML {
public var description: String { textContent(self) }
var listeners: [String: Listener] { [:] }
}

View File

@ -0,0 +1,10 @@
import JavaScriptKit
import TokamakDOM
let document = JSObjectRef.global.document.object!
let divElement = document.createElement!("div").object!
let renderer = DOMRenderer(Button("button", action: { print("hello") }), divElement)
let body = document.body.object!
_ = body.appendChild!(divElement)

View File

@ -2,6 +2,7 @@
// Created by Max Desiatov on 21/12/2018.
//
import Dispatch
import Tokamak
public final class TestRenderer: Renderer {
@ -13,7 +14,9 @@ public final class TestRenderer: Renderer {
public init<V: View>(_ view: V) {
// FIXME: the root target shouldn't be `EmptyView`, but something more sensible, maybe Group?
reconciler = StackReconciler(view: view, target: TestView(EmptyView()), renderer: self)
reconciler = StackReconciler(view: view, target: TestView(EmptyView()), renderer: self) {
DispatchQueue.main.async(execute: $0)
}
}
public func mountTarget(

View File

@ -21,7 +21,7 @@ public final class TestView: Target {
init<V: View>(_ view: V,
_ subviews: [TestView] = []) {
self.subviews = subviews
super.init(view: view)
super.init(view)
}
/** Add a subview to this test view.

8
Tests/LinuxMain.swift Normal file
View File

@ -0,0 +1,8 @@
import XCTest
import TokamakTests
var tests = [XCTestCaseEntry]()
tests += TokamakTests.__allTests()
XCTMain(tests)

View File

@ -0,0 +1,31 @@
#if !canImport(ObjectiveC)
import XCTest
extension ColorTests {
// DO NOT MODIFY: This is autogenerated, use:
// `swift test --generate-linuxmain`
// to regenerate.
static let __allTests__ColorTests = [
("testHexColors", testHexColors),
]
}
extension ReconcilerTests {
// DO NOT MODIFY: This is autogenerated, use:
// `swift test --generate-linuxmain`
// to regenerate.
static let __allTests__ReconcilerTests = [
("testDoubleUpdate", testDoubleUpdate),
("testMount", testMount),
("testUnmount", testUnmount),
("testUpdate", testUpdate),
]
}
public func __allTests() -> [XCTestCaseEntry] {
return [
testCase(ColorTests.__allTests__ColorTests),
testCase(ReconcilerTests.__allTests__ReconcilerTests),
]
}
#endif