@swiftbrowser/native 0.0.0-stage → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -2
- package/bin/swiftbrowser-native.mjs +49 -0
- package/dist/bridge.d.ts +50 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +10 -0
- package/dist/bridge.js.map +1 -0
- package/dist/build.d.ts +47 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +156 -0
- package/dist/build.js.map +1 -0
- package/dist/devBuild.d.ts +34 -0
- package/dist/devBuild.d.ts.map +1 -0
- package/dist/devBuild.js +133 -0
- package/dist/devBuild.js.map +1 -0
- package/dist/encoding.d.ts +32 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +136 -0
- package/dist/encoding.js.map +1 -0
- package/dist/fetch.d.ts +117 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +272 -0
- package/dist/fetch.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +19 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +17 -0
- package/dist/native.js.map +1 -0
- package/dist/refresh.d.ts +18 -0
- package/dist/refresh.d.ts.map +1 -0
- package/dist/refresh.js +18 -0
- package/dist/refresh.js.map +1 -0
- package/dist/run.d.ts +27 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +76 -0
- package/dist/run.js.map +1 -0
- package/dist/runtime.d.ts +30 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +193 -0
- package/dist/runtime.js.map +1 -0
- package/dist/stubs/react-dom-client.d.ts +8 -0
- package/dist/stubs/react-dom-client.d.ts.map +1 -0
- package/dist/stubs/react-dom-client.js +11 -0
- package/dist/stubs/react-dom-client.js.map +1 -0
- package/dist/stubs/react-dom.d.ts +9 -0
- package/dist/stubs/react-dom.d.ts.map +1 -0
- package/dist/stubs/react-dom.js +13 -0
- package/dist/stubs/react-dom.js.map +1 -0
- package/dist/url.d.ts +48 -0
- package/dist/url.d.ts.map +1 -0
- package/dist/url.js +213 -0
- package/dist/url.js.map +1 -0
- package/dist/vendor.d.ts +11 -0
- package/dist/vendor.d.ts.map +1 -0
- package/dist/vendor.js +27 -0
- package/dist/vendor.js.map +1 -0
- package/dist/wasmHost.d.ts +35 -0
- package/dist/wasmHost.d.ts.map +1 -0
- package/dist/wasmHost.js +120 -0
- package/dist/wasmHost.js.map +1 -0
- package/package.json +73 -4
- package/src/bridge.ts +58 -0
- package/src/build.ts +185 -0
- package/src/devBuild.ts +152 -0
- package/src/encoding.ts +130 -0
- package/src/fetch.ts +305 -0
- package/src/index.ts +20 -0
- package/src/native.ts +35 -0
- package/src/refresh.ts +29 -0
- package/src/run.ts +98 -0
- package/src/runtime.ts +208 -0
- package/src/stubs/react-dom-client.ts +17 -0
- package/src/stubs/react-dom.ts +15 -0
- package/src/types/react-refresh.d.ts +38 -0
- package/src/url.ts +202 -0
- package/src/vendor.ts +30 -0
- package/src/wasmHost.ts +147 -0
- package/swift/Package.swift +31 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +228 -0
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +42 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +102 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +25 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebHost.swift +138 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +251 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +53 -0
- package/swift/Sources/SwiftBrowserNative/SwiftBrowserNative.swift +6 -0
- package/swift/Sources/SwiftBrowserNative/Views/Conversions.swift +361 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +169 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +268 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +175 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +196 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +246 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +108 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +189 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +102 -0
- package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +190 -0
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +182 -0
- package/swift/Sources/SwiftBrowserProtocol/JSONValue.swift +268 -0
- package/swift/Sources/SwiftBrowserProtocol/Op.swift +100 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +469 -0
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +117 -0
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +148 -0
- package/swift/Sources/SwiftBrowserProtocol/Values.swift +279 -0
- package/swift/Sources/SwiftBrowserTree/ElementNode.swift +65 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +168 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
import SwiftBrowserTree
|
|
5
|
+
import SwiftUI
|
|
6
|
+
|
|
7
|
+
struct VStackElement: View {
|
|
8
|
+
let node: ElementNode
|
|
9
|
+
var body: some View {
|
|
10
|
+
let props = StackProps(props: node.props)
|
|
11
|
+
VStack(alignment: HorizontalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
|
|
12
|
+
ChildrenView(node: node)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// `hstack`; a `Label` in its default style (the marked icon, `labelIcon` on
|
|
18
|
+
/// its `styled` wrapper, then the title) is a `Label`, so iOS lays it out and
|
|
19
|
+
/// colors it as one: the icon column and tint in a list row, the inherited
|
|
20
|
+
/// foreground elsewhere.
|
|
21
|
+
struct HStackElement: View {
|
|
22
|
+
let node: ElementNode
|
|
23
|
+
var body: some View {
|
|
24
|
+
let props = StackProps(props: node.props)
|
|
25
|
+
if node.children.count == 2, let icon = node.children.first, icon.kind == "styled", icon.style.labelIcon {
|
|
26
|
+
Label {
|
|
27
|
+
ElementView(node: node.children[1])
|
|
28
|
+
} icon: {
|
|
29
|
+
ElementView(node: icon)
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
HStack(alignment: VerticalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
|
|
33
|
+
ChildrenView(node: node)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
struct ZStackElement: View {
|
|
40
|
+
let node: ElementNode
|
|
41
|
+
var body: some View {
|
|
42
|
+
ZStack(alignment: Alignment(name: StackProps(props: node.props).alignment)) {
|
|
43
|
+
ChildrenView(node: node)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// `.overlay { }` / `.background { }` with a view: children are `[content, decoration]`.
|
|
49
|
+
struct DecoratedElement: View {
|
|
50
|
+
let node: ElementNode
|
|
51
|
+
var body: some View {
|
|
52
|
+
let props = DecoratedProps(props: node.props)
|
|
53
|
+
let alignment = Alignment(name: props.alignment)
|
|
54
|
+
if let content = node.children.first {
|
|
55
|
+
// The decoration is the child after the content: a `ForEach` over
|
|
56
|
+
// it rather than an `if let`, which is a conditional decoration
|
|
57
|
+
// to SwiftUI (aligned by its edges, its guides unused).
|
|
58
|
+
let decoration = Array(node.children.dropFirst().prefix(1))
|
|
59
|
+
if props.role == "background" {
|
|
60
|
+
ElementView(node: content).background(alignment: alignment) {
|
|
61
|
+
ForEach(decoration) { ElementView(node: $0) }
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
ElementView(node: content).overlay(alignment: alignment) {
|
|
65
|
+
ForEach(decoration) { ElementView(node: $0) }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// `GeometryReader`: fills its proposal and reports its size, after the
|
|
73
|
+
/// layout pass (the tree is never mutated during one).
|
|
74
|
+
struct GeometryElement: View {
|
|
75
|
+
let node: ElementNode
|
|
76
|
+
@Environment(\.nativeApp) private var app
|
|
77
|
+
@State private var reported: CGRect = .null
|
|
78
|
+
|
|
79
|
+
var body: some View {
|
|
80
|
+
let props = GeometryProps(props: node.props)
|
|
81
|
+
GeometryReader { proxy in
|
|
82
|
+
let frame = proxy.frame(in: .global)
|
|
83
|
+
ChildrenView(node: node)
|
|
84
|
+
.onChange(of: frame, initial: true) { _, frame in
|
|
85
|
+
report(frame, reportsFrame: props.reportsFrame)
|
|
86
|
+
}
|
|
87
|
+
.onChange(of: props.reader) { _, _ in
|
|
88
|
+
// A new reader has not heard the size: report it again.
|
|
89
|
+
reported = .null
|
|
90
|
+
report(frame, reportsFrame: props.reportsFrame)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private func report(_ frame: CGRect, reportsFrame: Bool) {
|
|
96
|
+
let rounded = CGRect(x: frame.minX.rounded(), y: frame.minY.rounded(), width: frame.width.rounded(), height: frame.height.rounded())
|
|
97
|
+
let same = reportsFrame ? rounded == reported : rounded.size == reported.size
|
|
98
|
+
if same { return }
|
|
99
|
+
reported = rounded
|
|
100
|
+
let event: RendererEvent = reportsFrame
|
|
101
|
+
? .geometry(node.id, width: rounded.width, height: rounded.height, x: rounded.minX, y: rounded.minY)
|
|
102
|
+
: .geometry(node.id, width: rounded.width, height: rounded.height)
|
|
103
|
+
// Past the pass that measured it, as the web renderer batches geometry events.
|
|
104
|
+
let app = app
|
|
105
|
+
Task { @MainActor in app?.dispatch(event) }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
struct ScrollViewElement: View {
|
|
110
|
+
let node: ElementNode
|
|
111
|
+
@Environment(\.nativeApp) private var app
|
|
112
|
+
|
|
113
|
+
var body: some View {
|
|
114
|
+
let props = ScrollViewProps(props: node.props)
|
|
115
|
+
let axes: Axis.Set = props.axes == "horizontal" ? .horizontal : props.axes == "both" ? [.horizontal, .vertical] : .vertical
|
|
116
|
+
let scroll = ScrollView(axes, showsIndicators: props.showsIndicators) {
|
|
117
|
+
ChildrenView(node: node)
|
|
118
|
+
}
|
|
119
|
+
if props.refreshable {
|
|
120
|
+
scroll.refreshable { app?.dispatch(.refresh(node.id)) }
|
|
121
|
+
} else {
|
|
122
|
+
scroll
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// `LazyVGrid`: `columns` are `GridItem`s; children are the cells.
|
|
128
|
+
struct LazyVGridElement: View {
|
|
129
|
+
let node: ElementNode
|
|
130
|
+
var body: some View {
|
|
131
|
+
LazyVGrid(
|
|
132
|
+
columns: gridItems(node.props.array("columns") ?? []),
|
|
133
|
+
alignment: HorizontalAlignment(name: node.props.string("alignment") ?? "center"),
|
|
134
|
+
spacing: node.props.double("spacing").map { CGFloat($0) }
|
|
135
|
+
) {
|
|
136
|
+
ChildrenView(node: node)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
struct LazyHGridElement: View {
|
|
142
|
+
let node: ElementNode
|
|
143
|
+
var body: some View {
|
|
144
|
+
LazyHGrid(
|
|
145
|
+
rows: gridItems(node.props.array("rows") ?? []),
|
|
146
|
+
alignment: VerticalAlignment(name: node.props.string("alignment") ?? "center"),
|
|
147
|
+
spacing: node.props.double("spacing").map { CGFloat($0) }
|
|
148
|
+
) {
|
|
149
|
+
ChildrenView(node: node)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/// `GridItem` JSON: `{ size: { kind: fixed | flexible | adaptive, ... }, spacing, alignment }`.
|
|
155
|
+
func gridItems(_ items: [JSONValue]) -> [GridItem] {
|
|
156
|
+
items.map { item in
|
|
157
|
+
let size = item["size"]
|
|
158
|
+
let gridSize: GridItem.Size
|
|
159
|
+
switch size["kind"].string {
|
|
160
|
+
case "fixed": gridSize = .fixed(size["value"].double ?? 0)
|
|
161
|
+
case "adaptive": gridSize = .adaptive(minimum: size["minimum"].double ?? 10, maximum: CGFloat(dimension: DimensionSpec(json: size["maximum"])) ?? .infinity)
|
|
162
|
+
default: gridSize = .flexible(minimum: size["minimum"].double ?? 10, maximum: CGFloat(dimension: DimensionSpec(json: size["maximum"])) ?? .infinity)
|
|
163
|
+
}
|
|
164
|
+
return GridItem(gridSize, spacing: item["spacing"].double.map { CGFloat($0) }, alignment: item["alignment"].string.map(Alignment.init(name:)))
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
struct GridElement: View {
|
|
169
|
+
let node: ElementNode
|
|
170
|
+
var body: some View {
|
|
171
|
+
Grid(
|
|
172
|
+
alignment: Alignment(name: node.props.string("alignment") ?? "center"),
|
|
173
|
+
horizontalSpacing: node.props.double("horizontalSpacing").map { CGFloat($0) },
|
|
174
|
+
verticalSpacing: node.props.double("verticalSpacing").map { CGFloat($0) }
|
|
175
|
+
) {
|
|
176
|
+
ChildrenView(node: node)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
struct GridRowElement: View {
|
|
182
|
+
let node: ElementNode
|
|
183
|
+
var body: some View {
|
|
184
|
+
GridRow(alignment: node.props.string("alignment").map(VerticalAlignment.init(name:))) {
|
|
185
|
+
ChildrenView(node: node)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
#endif
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
import SwiftBrowserTree
|
|
5
|
+
import SwiftUI
|
|
6
|
+
#if canImport(FoundationBridge)
|
|
7
|
+
import FoundationBridge // `AsyncImage`, against the shim
|
|
8
|
+
#endif
|
|
9
|
+
|
|
10
|
+
/// `text`: a string, or rich runs concatenated into one `Text`.
|
|
11
|
+
struct TextElement: View {
|
|
12
|
+
let node: ElementNode
|
|
13
|
+
@Environment(\.nativeApp) private var app
|
|
14
|
+
|
|
15
|
+
var body: some View {
|
|
16
|
+
let props = TextProps(props: node.props)
|
|
17
|
+
if props.runs.isEmpty {
|
|
18
|
+
Text(verbatim: props.text)
|
|
19
|
+
} else {
|
|
20
|
+
richText(props.runs)
|
|
21
|
+
.environment(\.openURL, OpenURLAction { url in
|
|
22
|
+
// A tapped link run sends `link`; the app decides what opens.
|
|
23
|
+
app?.dispatch(.link(node.id, url.absoluteString))
|
|
24
|
+
return .handled
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private func richText(_ runs: [TextRunSpec]) -> Text {
|
|
30
|
+
let texts = runs.map(text(for:))
|
|
31
|
+
guard let first = texts.first else { return Text(verbatim: "") }
|
|
32
|
+
return texts.dropFirst().reduce(first, +)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private func text(for run: TextRunSpec) -> Text {
|
|
36
|
+
var text: Text
|
|
37
|
+
if let image = run.image {
|
|
38
|
+
switch image {
|
|
39
|
+
case .symbol(let name, _): text = Text(Image(systemName: name))
|
|
40
|
+
case .asset(let name, _): text = Text(Image(name))
|
|
41
|
+
default: text = Text(verbatim: "")
|
|
42
|
+
}
|
|
43
|
+
} else if let link = run.link, let url = URL(string: link) {
|
|
44
|
+
// Markdown gives a tappable link run; the text is escaped for it.
|
|
45
|
+
var string = AttributedString(run.text ?? "")
|
|
46
|
+
string.link = url
|
|
47
|
+
text = Text(string)
|
|
48
|
+
} else {
|
|
49
|
+
text = Text(verbatim: run.text ?? "")
|
|
50
|
+
}
|
|
51
|
+
if let font = run.font { text = text.font(Font(spec: font)) }
|
|
52
|
+
if run.italic { text = text.italic() }
|
|
53
|
+
if run.underline { text = text.underline(true, color: run.underlineColor.map(Color.init(spec:))) }
|
|
54
|
+
if run.strikethrough { text = text.strikethrough(true, color: run.strikethroughColor.map(Color.init(spec:))) }
|
|
55
|
+
if let kerning = run.kerning { text = text.kerning(kerning) }
|
|
56
|
+
if let offset = run.baselineOffset { text = text.baselineOffset(offset) }
|
|
57
|
+
if let foreground = run.foreground { text = text.foregroundStyle(AnyShapeStyle(spec: foreground)) }
|
|
58
|
+
return text
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// `image`: an SF Symbol (every name is right here, unlike the web's mapped
|
|
63
|
+
/// glyphs), an asset catalog image, a remote image, or a bitmap recipe (not drawn yet).
|
|
64
|
+
struct ImageElement: View {
|
|
65
|
+
let node: ElementNode
|
|
66
|
+
|
|
67
|
+
var body: some View {
|
|
68
|
+
switch ImageProps(props: node.props) {
|
|
69
|
+
// `ProgressView()` with no value reaches the protocol as this symbol.
|
|
70
|
+
case .symbol("progress.indicator", _):
|
|
71
|
+
ProgressView()
|
|
72
|
+
case .symbol(let name, let palette):
|
|
73
|
+
if palette {
|
|
74
|
+
Image(systemName: name).symbolRenderingMode(.palette)
|
|
75
|
+
} else {
|
|
76
|
+
Image(systemName: name)
|
|
77
|
+
}
|
|
78
|
+
case .asset(let name, let resizable):
|
|
79
|
+
if resizable {
|
|
80
|
+
Image(name).resizable()
|
|
81
|
+
} else {
|
|
82
|
+
Image(name)
|
|
83
|
+
}
|
|
84
|
+
case .url(let url, let resizable, let width, let height):
|
|
85
|
+
AsyncImage(url: URL(string: url)) { phase in
|
|
86
|
+
if let image = phase.image {
|
|
87
|
+
if resizable { image.resizable() } else { image }
|
|
88
|
+
} else if width > 0, height > 0 {
|
|
89
|
+
Color.clear.frame(width: width, height: height)
|
|
90
|
+
} else {
|
|
91
|
+
Color.clear
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
case .bitmap(_, _, let width, let height):
|
|
95
|
+
// A `UIImage` recipe the page would decode; not drawn natively yet.
|
|
96
|
+
Rectangle().fill(.quaternary).frame(width: width > 0 ? CGFloat(width) : nil, height: height > 0 ? CGFloat(height) : nil)
|
|
97
|
+
case .unknown:
|
|
98
|
+
Image(systemName: "questionmark.square.dashed")
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
#endif
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
import SwiftBrowserTree
|
|
5
|
+
import SwiftUI
|
|
6
|
+
|
|
7
|
+
/// The app on screen: the root container's children, with the root-level
|
|
8
|
+
/// `sheet` and `alert` elements presented over them, the root's
|
|
9
|
+
/// `.preferredColorScheme`, and the device environment sent to the app as it
|
|
10
|
+
/// changes. In Debug builds a JavaScript error shows over the app.
|
|
11
|
+
public struct NativeRootView: View {
|
|
12
|
+
let app: NativeApp
|
|
13
|
+
@Environment(\.colorScheme) private var colorScheme
|
|
14
|
+
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
|
15
|
+
@Environment(\.scenePhase) private var scenePhase
|
|
16
|
+
#if os(iOS) || canImport(FoundationBridge)
|
|
17
|
+
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
18
|
+
private var sizeClassName: String { horizontalSizeClass == .regular ? "regular" : "compact" }
|
|
19
|
+
#else
|
|
20
|
+
private var sizeClassName: String { "regular" }
|
|
21
|
+
#endif
|
|
22
|
+
|
|
23
|
+
public init(app: NativeApp) {
|
|
24
|
+
self.app = app
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public var body: some View {
|
|
28
|
+
let root = app.tree.root
|
|
29
|
+
let scheme = app.tree.rootProps.colorScheme
|
|
30
|
+
let preferred: ColorScheme? = scheme == "dark" ? .dark : scheme == "light" ? .light : nil
|
|
31
|
+
let presented = root.children.filter { $0.kind == "sheet" || $0.kind == "alert" }
|
|
32
|
+
let content = PresentationHost(presenter: root, presented: presented) {
|
|
33
|
+
ZStack {
|
|
34
|
+
ForEach(root.children.filter { $0.kind != "sheet" && $0.kind != "alert" }) { ElementView(node: $0) }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
.environment(\.nativeApp, app)
|
|
38
|
+
.preferredColorScheme(preferred)
|
|
39
|
+
.overlay(alignment: .bottom) { errorOverlay }
|
|
40
|
+
return observingEnvironment(content)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// The device environment, sent to the app as it changes. Type-erased
|
|
44
|
+
/// step by step: one chain of closures is more than the type checker
|
|
45
|
+
/// finishes in reasonable time.
|
|
46
|
+
private func observingEnvironment<V: View>(_ view: V) -> AnyView {
|
|
47
|
+
var observed = AnyView(view.onAppear { sendEnvironment() })
|
|
48
|
+
observed = AnyView(observed.onChange(of: colorScheme) { _, _ in sendEnvironment() })
|
|
49
|
+
observed = AnyView(observed.onChange(of: dynamicTypeSize) { _, _ in sendEnvironment() })
|
|
50
|
+
observed = AnyView(observed.onChange(of: sizeClassName) { _, _ in sendEnvironment() })
|
|
51
|
+
observed = AnyView(observed.onChange(of: scenePhase) { _, phase in app.setScenePhase(Self.name(of: phase)) })
|
|
52
|
+
observed = AnyView(observed.onChange(of: app.isLoaded) { _, loaded in
|
|
53
|
+
if loaded { sendEnvironment() }
|
|
54
|
+
})
|
|
55
|
+
return observed
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private static func name(of phase: ScenePhase) -> String {
|
|
59
|
+
switch phase {
|
|
60
|
+
case .active: return "active"
|
|
61
|
+
case .inactive: return "inactive"
|
|
62
|
+
default: return "background"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@ViewBuilder
|
|
67
|
+
private var errorOverlay: some View {
|
|
68
|
+
#if DEBUG
|
|
69
|
+
if let error = app.error {
|
|
70
|
+
ErrorBanner(message: error)
|
|
71
|
+
}
|
|
72
|
+
#endif
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private func sendEnvironment() {
|
|
76
|
+
app.setEnvironment(
|
|
77
|
+
colorScheme: colorScheme == .dark ? "dark" : "light",
|
|
78
|
+
dynamicTypeSize: dynamicTypeSize.protocolName,
|
|
79
|
+
horizontalSizeClass: sizeClassName
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Presents the first of `presented` over `content` (a sheet, a full-screen
|
|
85
|
+
/// cover or an alert), and the rest, in order, over that one. Dismissing one
|
|
86
|
+
/// sends `tap` with its id, as the protocol defines a dismiss request, and
|
|
87
|
+
/// the app removes it; the binding then reads nil.
|
|
88
|
+
struct PresentationHost<Content: View>: View {
|
|
89
|
+
let presenter: ElementNode
|
|
90
|
+
let presented: [ElementNode]
|
|
91
|
+
@ViewBuilder let content: () -> Content
|
|
92
|
+
@Environment(\.nativeApp) private var app
|
|
93
|
+
|
|
94
|
+
var body: some View {
|
|
95
|
+
let first = presented.first
|
|
96
|
+
let rest = Array(presented.dropFirst())
|
|
97
|
+
let sheet = first.flatMap { $0.kind == "sheet" && !SheetProps(props: $0.props).isFullScreen ? PresentedItem(node: $0) : nil }
|
|
98
|
+
let cover = first.flatMap { $0.kind == "sheet" && SheetProps(props: $0.props).isFullScreen ? PresentedItem(node: $0) : nil }
|
|
99
|
+
let alert = first.flatMap { $0.kind == "alert" ? $0 : nil }
|
|
100
|
+
let dismiss: (ElementNode) -> Void = { node in app?.dispatch(.tap(node.id)) }
|
|
101
|
+
let sheeted = content()
|
|
102
|
+
.sheet(item: Binding(get: { sheet }, set: { if $0 == nil, let sheet { dismiss(sheet.node) } })) { item in
|
|
103
|
+
SheetContent(node: item.node, rest: rest)
|
|
104
|
+
}
|
|
105
|
+
covered(sheeted, cover: cover, rest: rest, dismiss: dismiss)
|
|
106
|
+
.alert(
|
|
107
|
+
alert.map { AlertProps(props: $0.props).title } ?? "",
|
|
108
|
+
isPresented: Binding(get: { alert != nil }, set: { if !$0, let alert { dismiss(alert) } }),
|
|
109
|
+
presenting: alert
|
|
110
|
+
) { alert in
|
|
111
|
+
// The buttons: each sends its own tap; the app dismisses.
|
|
112
|
+
ForEach(alert.children) { ElementView(node: $0) }
|
|
113
|
+
} message: { alert in
|
|
114
|
+
if let message = AlertProps(props: alert.props).message {
|
|
115
|
+
Text(verbatim: message)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
extension PresentationHost {
|
|
122
|
+
/// `.fullScreenCover` where the platform has it.
|
|
123
|
+
@ViewBuilder
|
|
124
|
+
func covered<V: View>(_ view: V, cover: PresentedItem?, rest: [ElementNode], dismiss: @escaping (ElementNode) -> Void) -> some View {
|
|
125
|
+
#if os(iOS) || canImport(FoundationBridge)
|
|
126
|
+
view.fullScreenCover(item: Binding(get: { cover }, set: { if $0 == nil, let cover { dismiss(cover.node) } })) { item in
|
|
127
|
+
SheetContent(node: item.node, rest: rest)
|
|
128
|
+
}
|
|
129
|
+
#else
|
|
130
|
+
view
|
|
131
|
+
#endif
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
struct PresentedItem: Identifiable, Equatable {
|
|
136
|
+
let node: ElementNode
|
|
137
|
+
var id: Int { node.id }
|
|
138
|
+
static func == (lhs: PresentedItem, rhs: PresentedItem) -> Bool { lhs.node === rhs.node }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// A `sheet`'s content with its detents, and the presentations stacked over it.
|
|
142
|
+
struct SheetContent: View {
|
|
143
|
+
let node: ElementNode
|
|
144
|
+
let rest: [ElementNode]
|
|
145
|
+
@Environment(\.nativeApp) private var app
|
|
146
|
+
|
|
147
|
+
var body: some View {
|
|
148
|
+
let props = SheetProps(props: node.props)
|
|
149
|
+
PresentationHost(presenter: node, presented: rest) {
|
|
150
|
+
ZStack { ChildrenView(node: node) }
|
|
151
|
+
}
|
|
152
|
+
.environment(\.nativeApp, app)
|
|
153
|
+
.presentationDetents(detents(props.detents))
|
|
154
|
+
.interactiveDismissDisabled(props.interactiveDismissDisabled)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private func detents(_ names: [String]) -> Set<PresentationDetent> {
|
|
158
|
+
var detents: Set<PresentationDetent> = []
|
|
159
|
+
for name in names {
|
|
160
|
+
if name == "medium" {
|
|
161
|
+
detents.insert(.medium)
|
|
162
|
+
} else if name == "large" || name == "full" {
|
|
163
|
+
detents.insert(.large)
|
|
164
|
+
} else if name.hasPrefix("height:"), let value = Double(name.dropFirst("height:".count)) {
|
|
165
|
+
detents.insert(.height(value))
|
|
166
|
+
} else if name.hasPrefix("fraction:"), let value = Double(name.dropFirst("fraction:".count)) {
|
|
167
|
+
detents.insert(.fraction(value))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return detents.isEmpty ? [.large] : detents
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#if DEBUG
|
|
175
|
+
struct ErrorBanner: View {
|
|
176
|
+
let message: String
|
|
177
|
+
var body: some View {
|
|
178
|
+
ScrollView {
|
|
179
|
+
Text(verbatim: message)
|
|
180
|
+
.font(.system(.footnote, design: .monospaced))
|
|
181
|
+
.foregroundStyle(.white)
|
|
182
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
183
|
+
.padding()
|
|
184
|
+
}
|
|
185
|
+
.frame(maxHeight: 200)
|
|
186
|
+
.background(Color.red.opacity(0.92))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
#endif
|
|
190
|
+
#endif
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
import SwiftUI
|
|
5
|
+
|
|
6
|
+
/// A `styled` element's style as modifiers on its one child. Each nested
|
|
7
|
+
/// `styled` is one modifier of the app's, outermost last, so the nesting of
|
|
8
|
+
/// elements reproduces the app's modifier chain; the fields of one style are
|
|
9
|
+
/// order-independent (the shim only merges modifiers that commute).
|
|
10
|
+
///
|
|
11
|
+
/// Type-erased step by step: a chain of sixty optional modifiers would
|
|
12
|
+
/// otherwise be one type the compiler has to name.
|
|
13
|
+
extension View {
|
|
14
|
+
func styled(_ style: Style) -> AnyView {
|
|
15
|
+
var view = AnyView(self)
|
|
16
|
+
|
|
17
|
+
// Text attributes, which flow down the environment. A font with only a
|
|
18
|
+
// weight or design (`.fontWeight`, `.fontDesign`) adjusts the inherited one.
|
|
19
|
+
if let font = style.font {
|
|
20
|
+
if font.size == nil, font.textStyle == nil {
|
|
21
|
+
if let weight = font.weight { view = AnyView(view.fontWeight(Font.Weight(name: weight))) }
|
|
22
|
+
if let design = font.design { view = AnyView(view.fontDesign(Font.Design(name: design))) }
|
|
23
|
+
if font.monospacedDigit { view = AnyView(view.monospacedDigit()) }
|
|
24
|
+
} else {
|
|
25
|
+
view = AnyView(view.font(Font(spec: font)))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if style.italic == true { view = AnyView(view.italic()) }
|
|
29
|
+
if let underline = style.underline { view = AnyView(view.underline(underline)) }
|
|
30
|
+
if let strikethrough = style.strikethrough { view = AnyView(view.strikethrough(strikethrough)) }
|
|
31
|
+
if let foreground = style.foreground {
|
|
32
|
+
if style.foregroundLevels.count >= 2 {
|
|
33
|
+
view = AnyView(view.foregroundStyle(AnyShapeStyle(spec: foreground), AnyShapeStyle(spec: style.foregroundLevels[0]), AnyShapeStyle(spec: style.foregroundLevels[1])))
|
|
34
|
+
} else if style.foregroundLevels.count == 1 {
|
|
35
|
+
view = AnyView(view.foregroundStyle(AnyShapeStyle(spec: foreground), AnyShapeStyle(spec: style.foregroundLevels[0])))
|
|
36
|
+
} else {
|
|
37
|
+
view = AnyView(view.foregroundStyle(AnyShapeStyle(spec: foreground)))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if let alignment = style.multilineTextAlignment { view = AnyView(view.multilineTextAlignment(TextAlignment(name: alignment))) }
|
|
41
|
+
if style.lineLimitLifted {
|
|
42
|
+
view = AnyView(view.lineLimit(nil))
|
|
43
|
+
} else if let limit = style.lineLimit {
|
|
44
|
+
if let minimum = style.lineLimitMin {
|
|
45
|
+
view = AnyView(view.lineLimit(minimum...limit))
|
|
46
|
+
} else {
|
|
47
|
+
view = AnyView(view.lineLimit(limit))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if let scale = style.imageScale {
|
|
51
|
+
view = AnyView(view.imageScale(scale == "small" ? .small : scale == "large" ? .large : .medium))
|
|
52
|
+
}
|
|
53
|
+
if let tint = style.tint { view = AnyView(view.tint(Color(spec: tint))) }
|
|
54
|
+
// `labelIcon` marks a `Label`'s icon; HStackElement draws the label as one.
|
|
55
|
+
if let alignment = style.raw.string("gridColumnAlignment") { view = AnyView(view.gridColumnAlignment(HorizontalAlignment(name: alignment))) }
|
|
56
|
+
|
|
57
|
+
// Layout.
|
|
58
|
+
if style.fixedSizeHorizontal != nil || style.fixedSizeVertical != nil {
|
|
59
|
+
view = AnyView(view.fixedSize(horizontal: style.fixedSizeHorizontal ?? false, vertical: style.fixedSizeVertical ?? false))
|
|
60
|
+
}
|
|
61
|
+
if let priority = style.layoutPriority { view = AnyView(view.layoutPriority(priority)) }
|
|
62
|
+
if let padding = style.padding {
|
|
63
|
+
// `.padding(.vertical, 6)` pads the edges given; `EdgeInsets` would
|
|
64
|
+
// name them all, with 0 for the rest.
|
|
65
|
+
let given: [(Edge.Set, Double)] = [(.top, padding.top), (.leading, padding.leading), (.bottom, padding.bottom), (.trailing, padding.trailing)]
|
|
66
|
+
.compactMap { edge, length in length.map { (edge, $0) } }
|
|
67
|
+
if given.count < 4, let length = given.first?.1, given.allSatisfy({ $0.1 == length }) {
|
|
68
|
+
view = AnyView(view.padding(given.reduce(into: Edge.Set()) { $0.insert($1.0) }, CGFloat(length)))
|
|
69
|
+
} else {
|
|
70
|
+
view = AnyView(view.padding(EdgeInsets(spec: padding)))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if let frame = style.frame {
|
|
74
|
+
if frame.isFlexible {
|
|
75
|
+
view = AnyView(view.frame(
|
|
76
|
+
minWidth: CGFloat(dimension: frame.minWidth), maxWidth: CGFloat(dimension: frame.maxWidth),
|
|
77
|
+
minHeight: CGFloat(dimension: frame.minHeight), maxHeight: CGFloat(dimension: frame.maxHeight),
|
|
78
|
+
alignment: Alignment(name: frame.alignment)
|
|
79
|
+
))
|
|
80
|
+
} else {
|
|
81
|
+
view = AnyView(view.frame(width: frame.width.map { CGFloat($0) }, height: frame.height.map { CGFloat($0) }, alignment: Alignment(name: frame.alignment)))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if let ratio = style.aspectRatio {
|
|
85
|
+
let mode: ContentMode = style.aspectRatioContentMode == "fill" ? .fill : .fit
|
|
86
|
+
view = AnyView(view.aspectRatio(ratio.map { CGFloat($0) }, contentMode: mode))
|
|
87
|
+
}
|
|
88
|
+
if let position = style.position { view = AnyView(view.position(x: position.x, y: position.y)) }
|
|
89
|
+
|
|
90
|
+
// Paint.
|
|
91
|
+
if let background = style.background {
|
|
92
|
+
if let radius = style.backgroundCornerRadius {
|
|
93
|
+
view = AnyView(view.background(AnyShapeStyle(spec: background), in: RoundedRectangle(cornerRadius: radius, style: .continuous)))
|
|
94
|
+
} else {
|
|
95
|
+
view = AnyView(view.background(AnyShapeStyle(spec: background)))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if let radius = style.cornerRadius { view = AnyView(view.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))) }
|
|
99
|
+
if style.clipped { view = AnyView(view.clipped()) }
|
|
100
|
+
if let mask = style.mask { view = AnyView(view.mask { Rectangle().fill(AnyShapeStyle(spec: mask)) }) }
|
|
101
|
+
if let shadow = style.shadow {
|
|
102
|
+
view = AnyView(view.shadow(color: shadow.color.map(Color.init(spec:)) ?? Color.black.opacity(0.33), radius: shadow.radius, x: shadow.x, y: shadow.y))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Effects.
|
|
106
|
+
if let opacity = style.opacity { view = AnyView(view.opacity(opacity)) }
|
|
107
|
+
if let blur = style.blur { view = AnyView(view.blur(radius: blur)) }
|
|
108
|
+
if let brightness = style.brightness { view = AnyView(view.brightness(brightness)) }
|
|
109
|
+
if let saturation = style.saturation { view = AnyView(view.saturation(saturation)) }
|
|
110
|
+
if let contrast = style.contrast { view = AnyView(view.contrast(contrast)) }
|
|
111
|
+
if let grayscale = style.grayscale { view = AnyView(view.grayscale(grayscale)) }
|
|
112
|
+
if let hue = style.hueRotation { view = AnyView(view.hueRotation(.degrees(hue))) }
|
|
113
|
+
if style.colorInvert { view = AnyView(view.colorInvert()) }
|
|
114
|
+
if let multiply = style.colorMultiply { view = AnyView(view.colorMultiply(Color(spec: multiply))) }
|
|
115
|
+
if let blend = style.blendMode, let mode = BlendMode(name: blend) { view = AnyView(view.blendMode(mode)) }
|
|
116
|
+
if let rotation = style.rotation {
|
|
117
|
+
view = AnyView(view.rotationEffect(.degrees(rotation), anchor: style.rotationAnchor.map(UnitPoint.init(spec:)) ?? .center))
|
|
118
|
+
}
|
|
119
|
+
if let scale = style.scale {
|
|
120
|
+
view = AnyView(view.scaleEffect(x: scale.x, y: scale.y, anchor: style.scaleAnchor.map(UnitPoint.init(spec:)) ?? .center))
|
|
121
|
+
}
|
|
122
|
+
if let offset = style.offset { view = AnyView(view.offset(x: offset.x, y: offset.y)) }
|
|
123
|
+
if let zIndex = style.zIndex { view = AnyView(view.zIndex(zIndex)) }
|
|
124
|
+
|
|
125
|
+
// Interaction and visibility.
|
|
126
|
+
if style.disabled == true { view = AnyView(view.disabled(true)) }
|
|
127
|
+
if style.hitTesting == false { view = AnyView(view.allowsHitTesting(false)) }
|
|
128
|
+
if style.hidden { view = AnyView(view.hidden()) }
|
|
129
|
+
if let help = style.help { view = AnyView(view.help(help)) }
|
|
130
|
+
|
|
131
|
+
// Lists.
|
|
132
|
+
if let badge = style.badge { view = AnyView(view.badge(Text(badge))) }
|
|
133
|
+
if let insets = style.listRowInsets { view = AnyView(view.listRowInsets(EdgeInsets(spec: insets))) }
|
|
134
|
+
if let background = style.listRowBackground { view = AnyView(view.listRowBackground(Rectangle().fill(AnyShapeStyle(spec: background)))) }
|
|
135
|
+
|
|
136
|
+
// Safe area.
|
|
137
|
+
if let edges = style.ignoresSafeArea { view = AnyView(view.ignoresSafeArea(.container, edges: Edge.Set(names: edges))) }
|
|
138
|
+
if style.ignoresKeyboard { view = AnyView(view.ignoresSafeArea(.keyboard)) }
|
|
139
|
+
|
|
140
|
+
// Motion.
|
|
141
|
+
if let transition = style.transition { view = AnyView(view.transition(AnyTransition(specs: transition))) }
|
|
142
|
+
if let animation = style.animation {
|
|
143
|
+
// `.animation(_:value:)`: the token changes when the value does.
|
|
144
|
+
view = AnyView(view.animation(Animation(spec: animation), value: style.animationToken ?? 0))
|
|
145
|
+
}
|
|
146
|
+
if let kind = style.contentTransition {
|
|
147
|
+
switch kind {
|
|
148
|
+
case "numericText": view = AnyView(view.contentTransition(.numericText()))
|
|
149
|
+
case "interpolate": view = AnyView(view.contentTransition(.interpolate))
|
|
150
|
+
default: view = AnyView(view.contentTransition(.opacity))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return view
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
extension BlendMode {
|
|
158
|
+
init?(name: String) {
|
|
159
|
+
switch name {
|
|
160
|
+
case "normal": self = .normal
|
|
161
|
+
case "multiply": self = .multiply
|
|
162
|
+
case "screen": self = .screen
|
|
163
|
+
case "overlay": self = .overlay
|
|
164
|
+
case "darken": self = .darken
|
|
165
|
+
case "lighten": self = .lighten
|
|
166
|
+
case "color-dodge", "colorDodge": self = .colorDodge
|
|
167
|
+
case "color-burn", "colorBurn": self = .colorBurn
|
|
168
|
+
case "soft-light", "softLight": self = .softLight
|
|
169
|
+
case "hard-light", "hardLight": self = .hardLight
|
|
170
|
+
case "difference": self = .difference
|
|
171
|
+
case "exclusion": self = .exclusion
|
|
172
|
+
case "hue": self = .hue
|
|
173
|
+
case "saturation": self = .saturation
|
|
174
|
+
case "color": self = .color
|
|
175
|
+
case "luminosity": self = .luminosity
|
|
176
|
+
case "plus-lighter", "plusLighter": self = .plusLighter
|
|
177
|
+
case "plus-darker", "plusDarker": self = .plusDarker
|
|
178
|
+
default: return nil
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
#endif
|