@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,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