@swiftbrowser/native 0.0.0-stage → 0.10.0
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,196 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
import SwiftBrowserTree
|
|
5
|
+
import SwiftUI
|
|
6
|
+
|
|
7
|
+
/// `gauge`: a `Gauge` in one of its styles, or a determinate `ProgressView`
|
|
8
|
+
/// (`style: "progress"`). Children, in order: the label (when `hasLabel`)
|
|
9
|
+
/// and the current value label (when `hasCurrentValueLabel`).
|
|
10
|
+
struct GaugeElement: View {
|
|
11
|
+
let node: ElementNode
|
|
12
|
+
|
|
13
|
+
var body: some View {
|
|
14
|
+
let props = GaugeProps(props: node.props)
|
|
15
|
+
let label = props.hasLabel ? node.children.first : nil
|
|
16
|
+
// The protocol writes the current value label in the secondary color
|
|
17
|
+
// (a `styled` around it); SwiftUI draws the slot that way itself.
|
|
18
|
+
let current = (props.hasCurrentValueLabel ? node.children.dropFirst(props.hasLabel ? 1 : 0).first : nil).map { $0.secondaryColorContent ?? $0 }
|
|
19
|
+
if props.style == "progress" {
|
|
20
|
+
ProgressView(value: props.value) {
|
|
21
|
+
if let label { ElementView(node: label) }
|
|
22
|
+
} currentValueLabel: {
|
|
23
|
+
if let current { ElementView(node: current) }
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
let gauge = Gauge(value: props.value, in: 0...1) {
|
|
27
|
+
if let label { ElementView(node: label) }
|
|
28
|
+
} currentValueLabel: {
|
|
29
|
+
if let current { ElementView(node: current) }
|
|
30
|
+
}
|
|
31
|
+
switch props.style {
|
|
32
|
+
case "accessoryCircular": gauge.gaugeStyle(.accessoryCircular)
|
|
33
|
+
case "accessoryCircularCapacity": gauge.gaugeStyle(.accessoryCircularCapacity)
|
|
34
|
+
case "accessoryLinear": gauge.gaugeStyle(.accessoryLinear)
|
|
35
|
+
#if !canImport(FoundationBridge) // not in the shim (nor the web renderer)
|
|
36
|
+
case "accessoryLinearCapacity": gauge.gaugeStyle(.accessoryLinearCapacity)
|
|
37
|
+
#endif
|
|
38
|
+
case "linearCapacity": gauge.gaugeStyle(.linearCapacity)
|
|
39
|
+
default: gauge.gaugeStyle(.automatic)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// `datepicker`: a controlled `DatePicker`; the label is the child.
|
|
46
|
+
struct DatePickerElement: View {
|
|
47
|
+
let node: ElementNode
|
|
48
|
+
@Environment(\.nativeApp) private var app
|
|
49
|
+
|
|
50
|
+
var body: some View {
|
|
51
|
+
let props = DatePickerProps(props: node.props)
|
|
52
|
+
let selection = Binding<Date>(
|
|
53
|
+
get: { Date(timeIntervalSince1970: DatePickerProps(props: node.props).seconds) },
|
|
54
|
+
set: { app?.dispatch(.date(node.id, seconds: $0.timeIntervalSince1970.rounded(.down))) }
|
|
55
|
+
)
|
|
56
|
+
let components: DatePickerComponents = props.components == "hourAndMinute" ? [.hourAndMinute] : props.components == "dateAndTime" ? [.date, .hourAndMinute] : [.date]
|
|
57
|
+
let picker: AnyView = {
|
|
58
|
+
let minimum = props.min.map { Date(timeIntervalSince1970: $0) }
|
|
59
|
+
let maximum = props.max.map { Date(timeIntervalSince1970: $0) }
|
|
60
|
+
if let minimum, let maximum, minimum <= maximum {
|
|
61
|
+
return AnyView(DatePicker(selection: selection, in: minimum...maximum, displayedComponents: components) { ChildrenView(node: node) })
|
|
62
|
+
}
|
|
63
|
+
if let minimum {
|
|
64
|
+
return AnyView(DatePicker(selection: selection, in: minimum..., displayedComponents: components) { ChildrenView(node: node) })
|
|
65
|
+
}
|
|
66
|
+
if let maximum {
|
|
67
|
+
return AnyView(DatePicker(selection: selection, in: ...maximum, displayedComponents: components) { ChildrenView(node: node) })
|
|
68
|
+
}
|
|
69
|
+
return AnyView(DatePicker(selection: selection, displayedComponents: components) { ChildrenView(node: node) })
|
|
70
|
+
}()
|
|
71
|
+
let labeled = props.labelsHidden ? AnyView(picker.labelsHidden()) : picker
|
|
72
|
+
if let zone = props.timeZone, let timeZone = TimeZone(identifier: zone) {
|
|
73
|
+
labeled.environment(\.timeZone, timeZone)
|
|
74
|
+
} else {
|
|
75
|
+
labeled
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// `gesture`: one child; a drag sends `drag` events through its phases and
|
|
81
|
+
/// a long press sends `longpress`.
|
|
82
|
+
struct GestureElement: View {
|
|
83
|
+
let node: ElementNode
|
|
84
|
+
@Environment(\.nativeApp) private var app
|
|
85
|
+
@State private var dragging = false
|
|
86
|
+
|
|
87
|
+
var body: some View {
|
|
88
|
+
let props = GestureProps(props: node.props)
|
|
89
|
+
let content = ChildrenView(node: node).contentShape(Rectangle())
|
|
90
|
+
let dragged: AnyView = props.drag
|
|
91
|
+
? AnyView(content.gesture(
|
|
92
|
+
DragGesture(minimumDistance: props.minimumDistance)
|
|
93
|
+
.onChanged { value in
|
|
94
|
+
let phase = dragging ? "changed" : "began"
|
|
95
|
+
dragging = true
|
|
96
|
+
send(value, phase: phase)
|
|
97
|
+
}
|
|
98
|
+
.onEnded { value in
|
|
99
|
+
dragging = false
|
|
100
|
+
send(value, phase: "ended")
|
|
101
|
+
}
|
|
102
|
+
))
|
|
103
|
+
: AnyView(content)
|
|
104
|
+
if props.longPress {
|
|
105
|
+
dragged.onLongPressGesture(minimumDuration: props.minimumDuration) {
|
|
106
|
+
app?.dispatch(.longPress(node.id))
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
dragged
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private func send(_ value: DragGesture.Value, phase: String) {
|
|
114
|
+
app?.dispatch(.drag(
|
|
115
|
+
node.id, phase: phase,
|
|
116
|
+
translation: (value.translation.width, value.translation.height),
|
|
117
|
+
location: (value.location.x, value.location.y),
|
|
118
|
+
startLocation: (value.startLocation.x, value.startLocation.y),
|
|
119
|
+
velocity: (value.velocity.width, value.velocity.height)
|
|
120
|
+
))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// `viewthatfits`: the children are the candidates, in order.
|
|
125
|
+
struct ViewThatFitsElement: View {
|
|
126
|
+
let node: ElementNode
|
|
127
|
+
var body: some View {
|
|
128
|
+
let axes = ViewThatFitsProps(props: node.props).axes
|
|
129
|
+
ViewThatFits(in: axes == "horizontal" ? .horizontal : axes == "vertical" ? .vertical : [.horizontal, .vertical]) {
|
|
130
|
+
ChildrenView(node: node)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// `lazyspacer`: stands in for the rows not built yet; when it comes into
|
|
136
|
+
/// view (a `List` and the lazy stacks build rows as they scroll in) it asks
|
|
137
|
+
/// the app for the next batch with `lazy`.
|
|
138
|
+
struct LazySpacerElement: View {
|
|
139
|
+
let node: ElementNode
|
|
140
|
+
@Environment(\.nativeApp) private var app
|
|
141
|
+
var body: some View {
|
|
142
|
+
let props = LazySpacerProps(props: node.props)
|
|
143
|
+
Color.clear
|
|
144
|
+
.frame(width: props.axis == "horizontal" ? 1 : nil, height: props.axis == "vertical" ? 1 : nil)
|
|
145
|
+
.onAppear {
|
|
146
|
+
let app = app
|
|
147
|
+
let id = node.id
|
|
148
|
+
Task { @MainActor in app?.dispatch(.lazy(id)) }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Reports an element's laid-out size (and frame, with `reportsFrame`) with
|
|
154
|
+
/// `geometry` events, as a `GeometryReader` does, without changing its
|
|
155
|
+
/// layout: anchor preferences (`zstack`) and custom shapes (`shape`) ask for
|
|
156
|
+
/// it. Reported again when `reader` changes, since a new view has not heard it.
|
|
157
|
+
struct SizeReportModifier: ViewModifier {
|
|
158
|
+
let id: Int
|
|
159
|
+
let report: SizeReportProps
|
|
160
|
+
@Environment(\.nativeApp) private var app
|
|
161
|
+
@State private var reported: CGRect = .null
|
|
162
|
+
|
|
163
|
+
func body(content: Content) -> some View {
|
|
164
|
+
content.background(
|
|
165
|
+
GeometryReader { proxy in
|
|
166
|
+
let frame = proxy.frame(in: .global)
|
|
167
|
+
Color.clear
|
|
168
|
+
.onChange(of: frame, initial: true) { _, frame in send(frame) }
|
|
169
|
+
.onChange(of: report.reader) { _, _ in
|
|
170
|
+
reported = .null
|
|
171
|
+
send(frame)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private func send(_ frame: CGRect) {
|
|
178
|
+
let rounded = CGRect(x: frame.minX.rounded(), y: frame.minY.rounded(), width: frame.width.rounded(), height: frame.height.rounded())
|
|
179
|
+
if report.reportsFrame ? rounded == reported : rounded.size == reported.size { return }
|
|
180
|
+
reported = rounded
|
|
181
|
+
let event: RendererEvent = report.reportsFrame
|
|
182
|
+
? .geometry(id, width: rounded.width, height: rounded.height, x: rounded.minX, y: rounded.minY)
|
|
183
|
+
: .geometry(id, width: rounded.width, height: rounded.height)
|
|
184
|
+
let app = app
|
|
185
|
+
Task { @MainActor in app?.dispatch(event) }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
extension View {
|
|
190
|
+
/// Reports the element's size when its props ask for it.
|
|
191
|
+
func reportingSize(of node: ElementNode) -> AnyView {
|
|
192
|
+
let report = SizeReportProps(props: node.props)
|
|
193
|
+
return report.reportsSize || report.reportsFrame ? AnyView(modifier(SizeReportModifier(id: node.id, report: report))) : AnyView(self)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
#endif
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
import SwiftBrowserTree
|
|
5
|
+
import SwiftUI
|
|
6
|
+
|
|
7
|
+
/// `navstack`: the children are `navscreen`s, bottom to top. The protocol
|
|
8
|
+
/// expresses push and pop purely as children appearing and disappearing;
|
|
9
|
+
/// natively the path is derived from them. A native pop (the back button, an
|
|
10
|
+
/// edge swipe) sends `tap` with the popped screen's id, as the protocol says,
|
|
11
|
+
/// and the app removes the child in the same call, so the next read of the
|
|
12
|
+
/// path already agrees with the screen.
|
|
13
|
+
struct NavigationStackElement: View {
|
|
14
|
+
let node: ElementNode
|
|
15
|
+
@Environment(\.nativeApp) private var app
|
|
16
|
+
|
|
17
|
+
var body: some View {
|
|
18
|
+
let screens = node.children.filter { $0.kind == "navscreen" }
|
|
19
|
+
let pushed = screens.dropFirst().map(\.id)
|
|
20
|
+
let path = Binding<[Int]>(
|
|
21
|
+
get: { node.children.filter { $0.kind == "navscreen" }.dropFirst().map(\.id) },
|
|
22
|
+
set: { next in
|
|
23
|
+
// Only pops come from here (pushes come from the app).
|
|
24
|
+
for id in pushed where !next.contains(id) {
|
|
25
|
+
app?.dispatch(.tap(id))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
NavigationStack(path: path) {
|
|
30
|
+
Group {
|
|
31
|
+
if let root = screens.first {
|
|
32
|
+
NavigationScreenElement(node: root)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
.navigationDestination(for: Int.self) { id in
|
|
36
|
+
if let screen = node.children.first(where: { $0.id == id }) {
|
|
37
|
+
NavigationScreenElement(node: screen)
|
|
38
|
+
} else {
|
|
39
|
+
// Popped by the app already; SwiftUI is on its way out.
|
|
40
|
+
Color.clear
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// `navscreen`: the content, with its `toolbar` and `searchfield` children as
|
|
48
|
+
/// the bar's items and the search field, and its title.
|
|
49
|
+
struct NavigationScreenElement: View {
|
|
50
|
+
let node: ElementNode
|
|
51
|
+
@Environment(\.nativeApp) private var app
|
|
52
|
+
|
|
53
|
+
var body: some View {
|
|
54
|
+
let props = NavScreenProps(props: node.props)
|
|
55
|
+
let toolbars = node.children.filter { $0.kind == "toolbar" }
|
|
56
|
+
let search = node.children.first { $0.kind == "searchfield" }
|
|
57
|
+
let content = ZStack {
|
|
58
|
+
ForEach(node.children(except: ["toolbar", "searchfield"])) { ElementView(node: $0) }
|
|
59
|
+
}
|
|
60
|
+
let titled = content
|
|
61
|
+
.navigationTitle(props.title)
|
|
62
|
+
.toolbar { ScreenToolbar(toolbars: toolbars) }
|
|
63
|
+
let searched: AnyView = {
|
|
64
|
+
guard let search else { return AnyView(titled) }
|
|
65
|
+
let searchProps = SearchFieldProps(props: search.props)
|
|
66
|
+
return AnyView(titled.searchable(
|
|
67
|
+
text: Binding(get: { SearchFieldProps(props: search.props).text }, set: { app?.dispatch(.text(search.id, $0)) }),
|
|
68
|
+
prompt: Text(verbatim: searchProps.placeholder)
|
|
69
|
+
))
|
|
70
|
+
}()
|
|
71
|
+
#if os(iOS) || canImport(FoundationBridge)
|
|
72
|
+
let modeTitled = searched
|
|
73
|
+
.navigationBarTitleDisplayMode(props.displayMode == "inline" ? .inline : props.displayMode == "large" ? .large : .automatic)
|
|
74
|
+
#if canImport(FoundationBridge)
|
|
75
|
+
modeTitled // the shim has no `navigationBarBackButtonHidden` (nor does the protocol carry it)
|
|
76
|
+
#else
|
|
77
|
+
modeTitled.navigationBarBackButtonHidden(props.backButtonHidden)
|
|
78
|
+
#endif
|
|
79
|
+
#else
|
|
80
|
+
searched
|
|
81
|
+
#endif
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#if os(iOS) || canImport(FoundationBridge)
|
|
85
|
+
static let leadingPlacement: ToolbarItemPlacement = .topBarLeading
|
|
86
|
+
static let trailingPlacement: ToolbarItemPlacement = .topBarTrailing
|
|
87
|
+
static let bottomPlacement: ToolbarItemPlacement = .bottomBar
|
|
88
|
+
#else
|
|
89
|
+
static let leadingPlacement: ToolbarItemPlacement = .navigation
|
|
90
|
+
static let trailingPlacement: ToolbarItemPlacement = .primaryAction
|
|
91
|
+
static let bottomPlacement: ToolbarItemPlacement = .automatic
|
|
92
|
+
#endif
|
|
93
|
+
|
|
94
|
+
/// The protocol's placement names as SwiftUI's.
|
|
95
|
+
static func placement(_ name: String) -> ToolbarItemPlacement {
|
|
96
|
+
switch name {
|
|
97
|
+
case "leading": return leadingPlacement
|
|
98
|
+
case "principal": return .principal
|
|
99
|
+
case "secondary": return .secondaryAction
|
|
100
|
+
case "bottom": return bottomPlacement
|
|
101
|
+
default: return trailingPlacement // `trailing`, and `largeTitle` beside it
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// A screen's `toolbar` elements as toolbar content: one item per element
|
|
107
|
+
/// in the app's order, as the app made them, so items of one placement
|
|
108
|
+
/// group (and share the bar's glass) as the app's do. A toolbar content
|
|
109
|
+
/// builder takes no loop, so the elements go in by index, eight to a chunk
|
|
110
|
+
/// and the rest in the next.
|
|
111
|
+
struct ScreenToolbar: ToolbarContent {
|
|
112
|
+
let toolbars: [ElementNode]
|
|
113
|
+
var from = 0
|
|
114
|
+
|
|
115
|
+
var body: some ToolbarContent {
|
|
116
|
+
item(0)
|
|
117
|
+
item(1)
|
|
118
|
+
item(2)
|
|
119
|
+
item(3)
|
|
120
|
+
item(4)
|
|
121
|
+
item(5)
|
|
122
|
+
item(6)
|
|
123
|
+
item(7)
|
|
124
|
+
if from + 8 < toolbars.count {
|
|
125
|
+
ScreenToolbar(toolbars: toolbars, from: from + 8)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@ToolbarContentBuilder
|
|
130
|
+
private func item(_ offset: Int) -> some ToolbarContent {
|
|
131
|
+
if from + offset < toolbars.count {
|
|
132
|
+
ToolbarElement(node: toolbars[from + offset])
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// One `toolbar` element: a `ToolbarSpacer` for a `spacer` (iOS 26; a
|
|
138
|
+
/// `Spacer` item before it), else an item with the element's children,
|
|
139
|
+
/// without the bar's shared glass when `sharedBackground` is `hidden` (iOS 26).
|
|
140
|
+
struct ToolbarElement: ToolbarContent {
|
|
141
|
+
let node: ElementNode
|
|
142
|
+
|
|
143
|
+
var body: some ToolbarContent {
|
|
144
|
+
let props = ToolbarProps(props: node.props)
|
|
145
|
+
let placement = NavigationScreenElement.placement(props.placement)
|
|
146
|
+
if let spacer = props.spacer {
|
|
147
|
+
if #available(iOS 26, macOS 26, *) {
|
|
148
|
+
ToolbarSpacer(spacer == "fixed" ? .fixed : .flexible, placement: placement)
|
|
149
|
+
} else {
|
|
150
|
+
ToolbarItem(placement: placement) { Spacer() }
|
|
151
|
+
}
|
|
152
|
+
} else if props.sharedBackground == "hidden" {
|
|
153
|
+
if #available(iOS 26, macOS 26, *) {
|
|
154
|
+
ToolbarItem(placement: placement) { ChildrenView(node: node) }.sharedBackgroundVisibility(.hidden)
|
|
155
|
+
} else {
|
|
156
|
+
ToolbarItem(placement: placement) { ChildrenView(node: node) }
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
ToolbarItem(placement: placement) { ChildrenView(node: node) }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// `navlink`: a tap on it sends `tap`, and the app pushes. In a list it is
|
|
165
|
+
/// a row with a chevron, as the native link row is.
|
|
166
|
+
struct NavigationLinkElement: View {
|
|
167
|
+
let node: ElementNode
|
|
168
|
+
@Environment(\.nativeApp) private var app
|
|
169
|
+
|
|
170
|
+
var body: some View {
|
|
171
|
+
let props = NavLinkProps(props: node.props)
|
|
172
|
+
let inList = node.ancestor(ofKind: "list") != nil
|
|
173
|
+
Button {
|
|
174
|
+
app?.dispatch(.tap(node.id))
|
|
175
|
+
} label: {
|
|
176
|
+
if inList {
|
|
177
|
+
// The disclosure chevron in the trailing 22 points, as measured
|
|
178
|
+
// against iOS's row: 13 pt semibold, 2 in from the content's edge
|
|
179
|
+
// (the web renderer's LIST_CHEVRON_WIDTH reserves 20).
|
|
180
|
+
HStack(spacing: 0) {
|
|
181
|
+
ChildrenView(node: node)
|
|
182
|
+
Spacer(minLength: 0)
|
|
183
|
+
Image(systemName: "chevron.right")
|
|
184
|
+
.font(.system(size: 13, weight: .semibold))
|
|
185
|
+
.foregroundStyle(.tertiary)
|
|
186
|
+
.frame(width: 20, alignment: .trailing)
|
|
187
|
+
.padding(.trailing, 2)
|
|
188
|
+
}
|
|
189
|
+
.contentShape(Rectangle())
|
|
190
|
+
} else {
|
|
191
|
+
ChildrenView(node: node)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
.buttonStyle(.plain)
|
|
195
|
+
.foregroundStyle(inList || props.plain ? AnyShapeStyle(HierarchicalShapeStyle.primary) : AnyShapeStyle(TintShapeStyle()))
|
|
196
|
+
.listRowBackground(props.selected ? Color.accentColor.opacity(0.15) : nil)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// `tabview`: one `tab` child per tab; the bar item shows its title and symbol.
|
|
201
|
+
struct TabViewElement: View {
|
|
202
|
+
let node: ElementNode
|
|
203
|
+
@Environment(\.nativeApp) private var app
|
|
204
|
+
|
|
205
|
+
var body: some View {
|
|
206
|
+
let tabs = node.children.filter { $0.kind == "tab" }
|
|
207
|
+
let selection = Binding(
|
|
208
|
+
get: { TabViewProps(props: node.props).selected },
|
|
209
|
+
set: { app?.dispatch(.select(node.id, $0)) }
|
|
210
|
+
)
|
|
211
|
+
if #available(iOS 18, macOS 15, *) {
|
|
212
|
+
TabView(selection: selection) {
|
|
213
|
+
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
|
|
214
|
+
let props = TabProps(props: tab.props)
|
|
215
|
+
Tab(props.title, systemImage: props.systemImage ?? "", value: index) {
|
|
216
|
+
ChildrenView(node: tab)
|
|
217
|
+
}
|
|
218
|
+
.badge(props.badge.map { Text(verbatim: $0) })
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
TabView(selection: selection) {
|
|
223
|
+
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
|
|
224
|
+
let props = TabProps(props: tab.props)
|
|
225
|
+
let item = ChildrenView(node: tab)
|
|
226
|
+
.tabItem {
|
|
227
|
+
if let symbol = props.systemImage {
|
|
228
|
+
Label(props.title, systemImage: symbol)
|
|
229
|
+
} else if let image = props.image {
|
|
230
|
+
Label(props.title, image: image)
|
|
231
|
+
} else {
|
|
232
|
+
Text(verbatim: props.title)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
.tag(index)
|
|
236
|
+
if let badge = props.badge {
|
|
237
|
+
item.badge(Text(verbatim: badge))
|
|
238
|
+
} else {
|
|
239
|
+
item
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
#endif
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
import SwiftBrowserTree
|
|
5
|
+
import SwiftUI
|
|
6
|
+
|
|
7
|
+
/// `color`: a box filled with the color.
|
|
8
|
+
struct ColorElement: View {
|
|
9
|
+
let node: ElementNode
|
|
10
|
+
var body: some View {
|
|
11
|
+
ColorProps(props: node.props).color.map(Color.init(spec:)) ?? Color.clear
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// `shape`: a built-in shape with a fill (null: the foreground) and a stroke.
|
|
16
|
+
struct ShapeElement: View {
|
|
17
|
+
let node: ElementNode
|
|
18
|
+
|
|
19
|
+
var body: some View {
|
|
20
|
+
let props = ShapeProps(props: node.props)
|
|
21
|
+
let shape: AnyShape = {
|
|
22
|
+
let base = AnyShape(spec: props)
|
|
23
|
+
if let from = props.trimFrom, let to = props.trimTo { return AnyShape(base.trim(from: from, to: to)) }
|
|
24
|
+
return base
|
|
25
|
+
}()
|
|
26
|
+
let fill: AnyShapeStyle? = props.fill.map(AnyShapeStyle.init(spec:)) ?? (props.fillIsNull && props.stroke == nil ? AnyShapeStyle(ForegroundStyle()) : nil)
|
|
27
|
+
ZStack {
|
|
28
|
+
if let fill {
|
|
29
|
+
shape.fill(fill)
|
|
30
|
+
}
|
|
31
|
+
if let stroke = props.stroke {
|
|
32
|
+
let style = StrokeStyle(lineWidth: stroke.lineWidth, dash: stroke.dash.map { CGFloat($0) })
|
|
33
|
+
let paint = stroke.style.map(AnyShapeStyle.init(spec:)) ?? AnyShapeStyle(ForegroundStyle())
|
|
34
|
+
if stroke.inset, props.trimFrom == nil, let insettable = props.insettableShape {
|
|
35
|
+
strokeBorder(insettable, paint, style)
|
|
36
|
+
} else if stroke.inset {
|
|
37
|
+
// A path (or a trimmed shape) cannot be inset: padding the
|
|
38
|
+
// stroked copy by half the line keeps the line inside.
|
|
39
|
+
shape.stroke(paint, style: style).padding(stroke.lineWidth / 2)
|
|
40
|
+
} else {
|
|
41
|
+
shape.stroke(paint, style: style)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// `.strokeBorder` needs the concrete shape (the existential is opened here).
|
|
49
|
+
private func strokeBorder(_ shape: some InsettableShape, _ paint: AnyShapeStyle, _ style: StrokeStyle) -> AnyView {
|
|
50
|
+
AnyView(shape.strokeBorder(paint, style: style))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
extension ShapeProps {
|
|
54
|
+
/// The built-in shape, which can be inset; nil for a path.
|
|
55
|
+
var insettableShape: (any InsettableShape)? {
|
|
56
|
+
switch shape {
|
|
57
|
+
case "circle": return Circle()
|
|
58
|
+
case "capsule": return Capsule()
|
|
59
|
+
case "ellipse": return Ellipse()
|
|
60
|
+
case "roundedRectangle", "containerRelative": return RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
61
|
+
case "path": return nil
|
|
62
|
+
default: return Rectangle()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
extension AnyShape {
|
|
68
|
+
init(spec: ShapeProps) {
|
|
69
|
+
switch spec.shape {
|
|
70
|
+
case "circle": self = AnyShape(Circle())
|
|
71
|
+
case "capsule": self = AnyShape(Capsule())
|
|
72
|
+
case "ellipse": self = AnyShape(Ellipse())
|
|
73
|
+
case "roundedRectangle", "containerRelative": self = AnyShape(RoundedRectangle(cornerRadius: spec.cornerRadius, style: .continuous))
|
|
74
|
+
case "path":
|
|
75
|
+
#if canImport(FoundationBridge)
|
|
76
|
+
// The shim's `Path` does not parse SVG path data yet: nothing to draw.
|
|
77
|
+
self = AnyShape(NoPathShape())
|
|
78
|
+
#else
|
|
79
|
+
if let path = spec.path, !path.isEmpty, let parsed = Path(path) {
|
|
80
|
+
self = AnyShape(PathShape(path: parsed, size: spec.size.map { CGSize(width: $0.width, height: $0.height) }))
|
|
81
|
+
} else {
|
|
82
|
+
// No path yet (the size is not reported): nothing to draw.
|
|
83
|
+
self = AnyShape(NoPathShape())
|
|
84
|
+
}
|
|
85
|
+
#endif
|
|
86
|
+
default: self = AnyShape(Rectangle())
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// A custom shape from SVG path data (`Path(_:)` parses it). The app drew it
|
|
92
|
+
/// for the size the element reported (`size`); until the next report it is
|
|
93
|
+
/// stretched to the box, as the web renderer stretches its view box.
|
|
94
|
+
/// A shape with no path, for a custom shape before its first size report.
|
|
95
|
+
struct NoPathShape: Shape {
|
|
96
|
+
func path(in rect: CGRect) -> Path { Path() }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
struct PathShape: Shape {
|
|
100
|
+
let path: Path
|
|
101
|
+
let size: CGSize?
|
|
102
|
+
func path(in rect: CGRect) -> Path {
|
|
103
|
+
guard let size, size.width > 0, size.height > 0, size != rect.size else { return path.offsetBy(dx: rect.minX, dy: rect.minY) }
|
|
104
|
+
let transform = CGAffineTransform(translationX: rect.minX, y: rect.minY).scaledBy(x: rect.width / size.width, y: rect.height / size.height)
|
|
105
|
+
return path.applying(transform)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
#endif
|