@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.
Files changed (107) hide show
  1. package/README.md +143 -2
  2. package/bin/swiftbrowser-native.mjs +49 -0
  3. package/dist/bridge.d.ts +50 -0
  4. package/dist/bridge.d.ts.map +1 -0
  5. package/dist/bridge.js +10 -0
  6. package/dist/bridge.js.map +1 -0
  7. package/dist/build.d.ts +47 -0
  8. package/dist/build.d.ts.map +1 -0
  9. package/dist/build.js +156 -0
  10. package/dist/build.js.map +1 -0
  11. package/dist/devBuild.d.ts +34 -0
  12. package/dist/devBuild.d.ts.map +1 -0
  13. package/dist/devBuild.js +133 -0
  14. package/dist/devBuild.js.map +1 -0
  15. package/dist/encoding.d.ts +32 -0
  16. package/dist/encoding.d.ts.map +1 -0
  17. package/dist/encoding.js +136 -0
  18. package/dist/encoding.js.map +1 -0
  19. package/dist/fetch.d.ts +117 -0
  20. package/dist/fetch.d.ts.map +1 -0
  21. package/dist/fetch.js +272 -0
  22. package/dist/fetch.js.map +1 -0
  23. package/dist/index.d.ts +20 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +20 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/native.d.ts +19 -0
  28. package/dist/native.d.ts.map +1 -0
  29. package/dist/native.js +17 -0
  30. package/dist/native.js.map +1 -0
  31. package/dist/refresh.d.ts +18 -0
  32. package/dist/refresh.d.ts.map +1 -0
  33. package/dist/refresh.js +18 -0
  34. package/dist/refresh.js.map +1 -0
  35. package/dist/run.d.ts +27 -0
  36. package/dist/run.d.ts.map +1 -0
  37. package/dist/run.js +76 -0
  38. package/dist/run.js.map +1 -0
  39. package/dist/runtime.d.ts +30 -0
  40. package/dist/runtime.d.ts.map +1 -0
  41. package/dist/runtime.js +193 -0
  42. package/dist/runtime.js.map +1 -0
  43. package/dist/stubs/react-dom-client.d.ts +8 -0
  44. package/dist/stubs/react-dom-client.d.ts.map +1 -0
  45. package/dist/stubs/react-dom-client.js +11 -0
  46. package/dist/stubs/react-dom-client.js.map +1 -0
  47. package/dist/stubs/react-dom.d.ts +9 -0
  48. package/dist/stubs/react-dom.d.ts.map +1 -0
  49. package/dist/stubs/react-dom.js +13 -0
  50. package/dist/stubs/react-dom.js.map +1 -0
  51. package/dist/url.d.ts +48 -0
  52. package/dist/url.d.ts.map +1 -0
  53. package/dist/url.js +213 -0
  54. package/dist/url.js.map +1 -0
  55. package/dist/vendor.d.ts +11 -0
  56. package/dist/vendor.d.ts.map +1 -0
  57. package/dist/vendor.js +27 -0
  58. package/dist/vendor.js.map +1 -0
  59. package/dist/wasmHost.d.ts +35 -0
  60. package/dist/wasmHost.d.ts.map +1 -0
  61. package/dist/wasmHost.js +120 -0
  62. package/dist/wasmHost.js.map +1 -0
  63. package/package.json +73 -4
  64. package/src/bridge.ts +58 -0
  65. package/src/build.ts +185 -0
  66. package/src/devBuild.ts +152 -0
  67. package/src/encoding.ts +130 -0
  68. package/src/fetch.ts +305 -0
  69. package/src/index.ts +20 -0
  70. package/src/native.ts +35 -0
  71. package/src/refresh.ts +29 -0
  72. package/src/run.ts +98 -0
  73. package/src/runtime.ts +208 -0
  74. package/src/stubs/react-dom-client.ts +17 -0
  75. package/src/stubs/react-dom.ts +15 -0
  76. package/src/types/react-refresh.d.ts +38 -0
  77. package/src/url.ts +202 -0
  78. package/src/vendor.ts +30 -0
  79. package/src/wasmHost.ts +147 -0
  80. package/swift/Package.swift +31 -0
  81. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +228 -0
  82. package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +42 -0
  83. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +102 -0
  84. package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +25 -0
  85. package/swift/Sources/SwiftBrowserNative/Host/WebHost.swift +138 -0
  86. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +251 -0
  87. package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +53 -0
  88. package/swift/Sources/SwiftBrowserNative/SwiftBrowserNative.swift +6 -0
  89. package/swift/Sources/SwiftBrowserNative/Views/Conversions.swift +361 -0
  90. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +169 -0
  91. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +268 -0
  92. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +175 -0
  93. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +196 -0
  94. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +246 -0
  95. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +108 -0
  96. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +189 -0
  97. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +102 -0
  98. package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +190 -0
  99. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +182 -0
  100. package/swift/Sources/SwiftBrowserProtocol/JSONValue.swift +268 -0
  101. package/swift/Sources/SwiftBrowserProtocol/Op.swift +100 -0
  102. package/swift/Sources/SwiftBrowserProtocol/Props.swift +469 -0
  103. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +117 -0
  104. package/swift/Sources/SwiftBrowserProtocol/Style.swift +148 -0
  105. package/swift/Sources/SwiftBrowserProtocol/Values.swift +279 -0
  106. package/swift/Sources/SwiftBrowserTree/ElementNode.swift +65 -0
  107. 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