@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.
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,169 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+
7
+ /// One element of the tree as a SwiftUI view, by kind (docs/ops-protocol.md,
8
+ /// "Element kinds and props"), recursing into its children. The node is
9
+ /// observable: the body re-evaluates when this node's props or children
10
+ /// change, not when another element's do.
11
+ ///
12
+ /// Type-erased per element: the switch has one branch per kind, and a
13
+ /// `ForEach` over children keyed by the protocol's stable ids keeps view
14
+ /// identity (and so state, scroll positions and animations) across passes.
15
+ struct ElementView: View {
16
+ let node: ElementNode
17
+
18
+ var body: AnyView {
19
+ switch node.kind {
20
+ // Text and images
21
+ case "text": return AnyView(TextElement(node: node))
22
+ case "image": return AnyView(ImageElement(node: node))
23
+ // Stacks and layout
24
+ case "vstack": return AnyView(VStackElement(node: node))
25
+ case "hstack": return AnyView(HStackElement(node: node))
26
+ case "zstack": return ZStackElement(node: node).reportingSize(of: node)
27
+ case "spacer": return AnyView(Spacer(minLength: SpacerProps(props: node.props).minLength.map { CGFloat($0) }))
28
+ case "divider": return AnyView(Divider())
29
+ case "styled": return node.isSpinner ? AnyView(ProgressView()) : ChildrenView(node: node).styled(node.style)
30
+ case "decorated": return AnyView(DecoratedElement(node: node))
31
+ case "geometry": return AnyView(GeometryElement(node: node))
32
+ case "scrollview": return AnyView(ScrollViewElement(node: node))
33
+ case "viewthatfits": return AnyView(ViewThatFitsElement(node: node))
34
+ case "lazyspacer": return AnyView(LazySpacerElement(node: node))
35
+ case "lazyvgrid": return AnyView(LazyVGridElement(node: node))
36
+ case "lazyhgrid": return AnyView(LazyHGridElement(node: node))
37
+ case "grid": return AnyView(GridElement(node: node))
38
+ case "gridrow": return AnyView(GridRowElement(node: node))
39
+ // Controls
40
+ case "button": return AnyView(ButtonElement(node: node))
41
+ case "toggle": return AnyView(ToggleElement(node: node))
42
+ case "textfield": return AnyView(TextFieldElement(node: node))
43
+ case "texteditor": return AnyView(TextEditorElement(node: node))
44
+ case "picker": return AnyView(PickerElement(node: node))
45
+ case "slider": return AnyView(SliderElement(node: node))
46
+ case "stepper": return AnyView(StepperElement(node: node))
47
+ case "tapgesture": return AnyView(TapGestureElement(node: node))
48
+ case "gesture": return AnyView(GestureElement(node: node))
49
+ case "gauge": return AnyView(GaugeElement(node: node))
50
+ case "datepicker": return AnyView(DatePickerElement(node: node))
51
+ case "menu": return AnyView(MenuElement(node: node))
52
+ case "contextmenu": return AnyView(ContextMenuElement(node: node))
53
+ // Lists
54
+ case "list": return AnyView(ListElement(node: node))
55
+ case "section": return AnyView(SectionElement(node: node))
56
+ case "listrow": return AnyView(ListRowElement(node: node))
57
+ case "swipeactions": return AnyView(SwipeActionsElement(node: node))
58
+ // Navigation and presentation
59
+ case "navstack": return AnyView(NavigationStackElement(node: node))
60
+ case "navscreen": return AnyView(NavigationScreenElement(node: node))
61
+ case "navlink": return AnyView(NavigationLinkElement(node: node))
62
+ case "tabview": return AnyView(TabViewElement(node: node))
63
+ case "tab": return AnyView(ChildrenView(node: node))
64
+ case "sheet", "alert":
65
+ // Presented by the root (PresentationHost), never drawn in place.
66
+ return AnyView(EmptyView())
67
+ case "toolbar", "searchfield":
68
+ // Taken by the enclosing screen; in place they draw nothing.
69
+ return AnyView(EmptyView())
70
+ // Shapes and colors
71
+ case "color": return AnyView(ColorElement(node: node))
72
+ case "shape": return ShapeElement(node: node).reportingSize(of: node)
73
+ // The escape hatch
74
+ case "native": return AnyView(NativeElement(node: node))
75
+ default: return AnyView(UnsupportedElement(node: node))
76
+ }
77
+ }
78
+ }
79
+
80
+ /// The element's children, in order.
81
+ struct ChildrenView: View {
82
+ let node: ElementNode
83
+ var body: some View {
84
+ ForEach(node.children) { child in
85
+ ElementView(node: child)
86
+ }
87
+ }
88
+ }
89
+
90
+ /// A kind this renderer does not draw yet: its children, with the kind named
91
+ /// over them in Debug builds (the web renderer draws an unknown symbol as a
92
+ /// visible placeholder for the same reason).
93
+ struct UnsupportedElement: View {
94
+ let node: ElementNode
95
+ var body: some View {
96
+ VStack(spacing: 0) { ChildrenView(node: node) }
97
+ .overlay(alignment: .topLeading) { marker }
98
+ }
99
+
100
+ @ViewBuilder
101
+ private var marker: some View {
102
+ #if DEBUG
103
+ Text(node.kind)
104
+ .font(.caption2)
105
+ .padding(2)
106
+ .background(Color.red.opacity(0.8), in: RoundedRectangle(cornerRadius: 3))
107
+ .foregroundStyle(.white)
108
+ .allowsHitTesting(false)
109
+ #else
110
+ EmptyView()
111
+ #endif
112
+ }
113
+ }
114
+
115
+ /// The `native` kind: a view the Swift app registered under `name`.
116
+ struct NativeElement: View {
117
+ let node: ElementNode
118
+ @Environment(\.nativeApp) private var app
119
+
120
+ var body: some View {
121
+ let props = NativeProps(props: node.props)
122
+ let app = app
123
+ let context = NativeViewContext(id: node.id, props: props.props, children: node.children) { value in
124
+ app?.dispatch(.native(node.id, name: props.name, value: value))
125
+ }
126
+ if let view = NativeRegistry.view(name: props.name, context: context) {
127
+ view
128
+ } else {
129
+ UnsupportedElement(node: node)
130
+ }
131
+ }
132
+ }
133
+
134
+ // MARK: - Environment
135
+
136
+ private struct NativeAppKey: EnvironmentKey {
137
+ static let defaultValue: NativeApp? = nil
138
+ }
139
+
140
+ extension EnvironmentValues {
141
+ /// The app whose tree the views draw, for sending events.
142
+ var nativeApp: NativeApp? {
143
+ get { self[NativeAppKey.self] }
144
+ set { self[NativeAppKey.self] = newValue }
145
+ }
146
+ }
147
+ extension ElementNode {
148
+ /// The one child of a `styled` that only sets the secondary color, where
149
+ /// the protocol encodes a slot SwiftUI draws so by itself; nil otherwise.
150
+ var secondaryColorContent: ElementNode? {
151
+ guard kind == "styled", children.count == 1, Set(style.raw.keys) == ["foreground"],
152
+ case .hierarchical(level: 2, opacity: 1)? = style.foreground
153
+ else { return nil }
154
+ return children[0]
155
+ }
156
+
157
+ /// `ProgressView()` with no value, as the protocol emits it: the spinner
158
+ /// symbol at 20 points in the secondary color. Drawn as the real spinner.
159
+ var isSpinner: Bool {
160
+ guard kind == "styled", children.count == 1, case .hierarchical(level: 2, opacity: 1)? = style.foreground else { return false }
161
+ let sized = children[0]
162
+ guard sized.kind == "styled", sized.children.count == 1, sized.style.font?.size == 20, sized.style.font?.textStyle == nil,
163
+ sized.style.font?.weight == nil
164
+ else { return false }
165
+ let image = sized.children[0]
166
+ return image.kind == "image" && image.props.string("systemName") == "progress.indicator"
167
+ }
168
+ }
169
+ #endif
@@ -0,0 +1,268 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+
7
+ /// `button`: the child is the label; a tap sends `tap` and returns after the
8
+ /// app's render pass applied.
9
+ struct ButtonElement: View {
10
+ let node: ElementNode
11
+ @Environment(\.nativeApp) private var app
12
+
13
+ var body: some View {
14
+ let props = ButtonProps(props: node.props)
15
+ #if os(iOS) || canImport(FoundationBridge)
16
+ if props.editMode != nil {
17
+ // An `EditButton`: iOS draws it (the checkmark while editing). Its
18
+ // binding's setter is the tap; the app's next pass sets the mode.
19
+ EditButton().environment(\.editMode, Binding(
20
+ get: { ButtonProps(props: node.props).editMode == "active" ? .active : .inactive },
21
+ set: { _ in app?.dispatch(.tap(node.id)) }
22
+ ))
23
+ } else {
24
+ plain(props)
25
+ }
26
+ #else
27
+ plain(props)
28
+ #endif
29
+ }
30
+
31
+ @ViewBuilder
32
+ private func plain(_ props: ButtonProps) -> some View {
33
+ let button = Button(role: props.role == "destructive" ? .destructive : props.role == "cancel" ? .cancel : nil) {
34
+ app?.dispatch(.tap(node.id))
35
+ } label: {
36
+ ChildrenView(node: node)
37
+ }
38
+ let sized: AnyView = {
39
+ switch props.controlSize {
40
+ case "mini": return AnyView(button.controlSize(.mini))
41
+ case "small": return AnyView(button.controlSize(.small))
42
+ case "large": return AnyView(button.controlSize(.large))
43
+ case "extraLarge": return AnyView(button.controlSize(.extraLarge))
44
+ default: return AnyView(button)
45
+ }
46
+ }()
47
+ switch props.style {
48
+ // `.borderless` reaches the protocol as `plain` too; outside a list,
49
+ // where a plain-looking text button is tinted on iOS, draw it so.
50
+ case "plain": node.ancestor(ofKind: "list") != nil ? AnyView(sized.buttonStyle(.plain)) : AnyView(sized.buttonStyle(.borderless))
51
+ case "bordered": sized.buttonStyle(.bordered)
52
+ case "borderedProminent", "glass": sized.buttonStyle(.borderedProminent)
53
+ default: sized.buttonStyle(.automatic)
54
+ }
55
+ }
56
+ }
57
+
58
+ /// `toggle`: the binding's setter sends `toggle`; by the time it returns the
59
+ /// prop holds the app's new value, which the getter reads.
60
+ struct ToggleElement: View {
61
+ let node: ElementNode
62
+ @Environment(\.nativeApp) private var app
63
+
64
+ var body: some View {
65
+ Toggle(isOn: Binding(
66
+ get: { ToggleProps(props: node.props).isOn },
67
+ set: { app?.dispatch(.toggle(node.id, $0)) }
68
+ )) {
69
+ ChildrenView(node: node)
70
+ }
71
+ }
72
+ }
73
+
74
+ /// `textfield`: a controlled field. Each keystroke sends `text`; the app's
75
+ /// state is the truth the getter reads back, with no frame in between
76
+ /// because the dispatch is synchronous.
77
+ struct TextFieldElement: View {
78
+ let node: ElementNode
79
+ @Environment(\.nativeApp) private var app
80
+ @FocusState private var focused: Bool
81
+
82
+ var body: some View {
83
+ let props = TextFieldProps(props: node.props)
84
+ let text = Binding(
85
+ get: { TextFieldProps(props: node.props).text },
86
+ set: { app?.dispatch(.text(node.id, $0)) }
87
+ )
88
+ let field: AnyView = {
89
+ if props.secure { return AnyView(SecureField(props.placeholder, text: text)) }
90
+ if props.vertical { return AnyView(TextField(props.placeholder, text: text, axis: .vertical)) }
91
+ return AnyView(TextField(props.placeholder, text: text))
92
+ }()
93
+ var styled = props.style == "roundedBorder" ? AnyView(field.textFieldStyle(.roundedBorder)) : AnyView(field.textFieldStyle(.plain))
94
+ // Only what the app set: each of these is a trait of the field to SwiftUI.
95
+ if props.submit { styled = AnyView(styled.onSubmit { app?.dispatch(.submit(node.id)) }) }
96
+ if let label = props.submitLabel { styled = AnyView(styled.submitLabel(SubmitLabel(name: label))) }
97
+ if props.focused != nil {
98
+ styled = AnyView(styled
99
+ .focused($focused)
100
+ .onChange(of: props.focused) { _, wanted in
101
+ if let wanted, wanted != focused { focused = wanted }
102
+ }
103
+ .onChange(of: focused) { _, now in
104
+ if props.focused != now { app?.dispatch(.focus(node.id, now)) }
105
+ }
106
+ .onAppear { if props.focused == true { focused = true } })
107
+ }
108
+ return styled
109
+ }
110
+ }
111
+
112
+ extension SubmitLabel {
113
+ init(name: String?) {
114
+ switch name {
115
+ case "done": self = .done
116
+ case "go": self = .go
117
+ case "send": self = .send
118
+ case "join": self = .join
119
+ case "route": self = .route
120
+ case "search": self = .search
121
+ case "next": self = .next
122
+ case "continue": self = .continue
123
+ default: self = .return
124
+ }
125
+ }
126
+ }
127
+
128
+ /// `texteditor`: a multi-line controlled field.
129
+ struct TextEditorElement: View {
130
+ let node: ElementNode
131
+ @Environment(\.nativeApp) private var app
132
+
133
+ var body: some View {
134
+ TextEditor(text: Binding(
135
+ get: { node.props.string("text") ?? "" },
136
+ set: { app?.dispatch(.text(node.id, $0)) }
137
+ ))
138
+ }
139
+ }
140
+
141
+ /// `picker`: the options are strings; choosing sends `select` with the index.
142
+ struct PickerElement: View {
143
+ let node: ElementNode
144
+ @Environment(\.nativeApp) private var app
145
+
146
+ var body: some View {
147
+ let props = PickerProps(props: node.props)
148
+ let selection = Binding(
149
+ get: { PickerProps(props: node.props).selected },
150
+ set: { app?.dispatch(.select(node.id, $0)) }
151
+ )
152
+ let picker = Picker(props.label, selection: selection) {
153
+ ForEach(Array(props.options.enumerated()), id: \.offset) { index, option in
154
+ Text(verbatim: option).tag(index)
155
+ }
156
+ }
157
+ switch props.style {
158
+ case "segmented": picker.pickerStyle(.segmented)
159
+ case "inline": picker.pickerStyle(.inline)
160
+ case "wheel":
161
+ #if os(iOS) || canImport(FoundationBridge)
162
+ picker.pickerStyle(.wheel)
163
+ #else
164
+ picker.pickerStyle(.menu)
165
+ #endif
166
+ default: picker.pickerStyle(.menu)
167
+ }
168
+ }
169
+ }
170
+
171
+ struct SliderElement: View {
172
+ let node: ElementNode
173
+ @Environment(\.nativeApp) private var app
174
+
175
+ var body: some View {
176
+ let props = SliderProps(props: node.props)
177
+ let value = Binding(
178
+ get: { SliderProps(props: node.props).value },
179
+ set: { app?.dispatch(.slide(node.id, $0)) }
180
+ )
181
+ let range = props.min...max(props.min, props.max)
182
+ if node.children.isEmpty {
183
+ if let step = props.step, step > 0 {
184
+ Slider(value: value, in: range, step: step)
185
+ } else {
186
+ Slider(value: value, in: range)
187
+ }
188
+ } else if let step = props.step, step > 0 {
189
+ Slider(value: value, in: range, step: step) { ChildrenView(node: node) }
190
+ } else {
191
+ Slider(value: value, in: range) { ChildrenView(node: node) }
192
+ }
193
+ }
194
+ }
195
+
196
+ /// `stepper`: the label is the child; each half sends `step` ±1.
197
+ struct StepperElement: View {
198
+ let node: ElementNode
199
+ @Environment(\.nativeApp) private var app
200
+
201
+ var body: some View {
202
+ let props = StepperProps(props: node.props)
203
+ Stepper {
204
+ ChildrenView(node: node)
205
+ } onIncrement: {
206
+ if props.canIncrement { app?.dispatch(.step(node.id, 1)) }
207
+ } onDecrement: {
208
+ if props.canDecrement { app?.dispatch(.step(node.id, -1)) }
209
+ }
210
+ }
211
+ }
212
+
213
+ /// `.onTapGesture`: one child; a tap anywhere on it sends `tap`.
214
+ struct TapGestureElement: View {
215
+ let node: ElementNode
216
+ @Environment(\.nativeApp) private var app
217
+
218
+ var body: some View {
219
+ let props = TapGestureProps(props: node.props)
220
+ let content = ChildrenView(node: node).contentShape(Rectangle())
221
+ if props.location {
222
+ content.onTapGesture(coordinateSpace: .local) { point in
223
+ app?.dispatch(.tap(node.id, x: point.x, y: point.y))
224
+ }
225
+ } else {
226
+ content.onTapGesture { app?.dispatch(.tap(node.id)) }
227
+ }
228
+ }
229
+ }
230
+
231
+ /// `Menu { items } label: { label }`: children are `[label, ...items]`.
232
+ struct MenuElement: View {
233
+ let node: ElementNode
234
+ @Environment(\.nativeApp) private var app
235
+
236
+ var body: some View {
237
+ if let label = node.children.first {
238
+ Menu {
239
+ ForEach(node.children.dropFirst()) { ElementView(node: $0) }
240
+ } label: {
241
+ ElementView(node: label)
242
+ }
243
+ .onAppear {
244
+ // A lazy menu builds its items on `open`; natively the items
245
+ // are needed up front, so ask as soon as the menu is on screen.
246
+ if node.props.bool("deferred") == true { app?.dispatch(.menu(node.id, open: true)) }
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ /// `.contextMenu { }`: children are `[content, ...items]`.
253
+ struct ContextMenuElement: View {
254
+ let node: ElementNode
255
+ @Environment(\.nativeApp) private var app
256
+
257
+ var body: some View {
258
+ if let content = node.children.first {
259
+ ElementView(node: content).contextMenu {
260
+ ForEach(node.children.dropFirst()) { ElementView(node: $0) }
261
+ }
262
+ .onAppear {
263
+ if node.props.bool("deferred") == true { app?.dispatch(.menu(node.id, open: true)) }
264
+ }
265
+ }
266
+ }
267
+ }
268
+ #endif
@@ -0,0 +1,175 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+
7
+ /// `list`: every direct child that is not a `section` is one row. The
8
+ /// protocol's `listrow` wrapper carries the edit-mode flags; `onDelete` and
9
+ /// `onMove` on the rows of each container send `delete` and `move`.
10
+ struct ListElement: View {
11
+ let node: ElementNode
12
+ @Environment(\.nativeApp) private var app
13
+
14
+ var body: some View {
15
+ let props = ListProps(props: node.props)
16
+ let list = List {
17
+ ListRowsView(children: node.children)
18
+ }
19
+ let styled: AnyView = {
20
+ switch props.style {
21
+ case "plain": return AnyView(list.listStyle(.plain))
22
+ case "sidebar": return AnyView(list.listStyle(.sidebar))
23
+ // `grouped` is what a `Form` emits: a Form draws its controls (a
24
+ // menu picker's value, say) as a form does.
25
+ case "grouped": return AnyView(Form { ListRowsView(children: node.children) })
26
+ #if os(iOS) || canImport(FoundationBridge)
27
+ default: return AnyView(list.listStyle(.insetGrouped))
28
+ #else
29
+ default: return AnyView(list.listStyle(.inset))
30
+ #endif
31
+ }
32
+ }()
33
+ let refreshable = props.refreshable ? AnyView(styled.refreshable { app?.dispatch(.refresh(node.id)) }) : styled
34
+ #if os(iOS) || canImport(FoundationBridge)
35
+ if let editing = props.editing {
36
+ refreshable.environment(\.editMode, .constant(editing ? .active : .inactive))
37
+ } else {
38
+ refreshable
39
+ }
40
+ #else
41
+ refreshable
42
+ #endif
43
+ }
44
+ }
45
+
46
+ /// The rows of a `list` or `section`: sections render as `Section`s, the rest
47
+ /// as rows in one `ForEach` so `onMove` and `onDelete` see them as siblings.
48
+ struct ListRowsView: View {
49
+ /// The container's children (a `list`'s, or a `section`'s without its header and footer elements).
50
+ let children: [ElementNode]
51
+ @Environment(\.nativeApp) private var app
52
+
53
+ var body: some View {
54
+ let rows = children.filter { $0.kind != "section" }
55
+ let movable = rows.contains { $0.kind == "listrow" && ListRowProps(props: $0.props).movable }
56
+ let deletable = rows.contains { $0.kind == "listrow" && ListRowProps(props: $0.props).deletable }
57
+ let onDelete: ((IndexSet) -> Void)? = deletable
58
+ ? { offsets in
59
+ for offset in offsets.sorted(by: >) where rows.indices.contains(offset) {
60
+ app?.dispatch(.delete(rows[offset].id))
61
+ }
62
+ }
63
+ : nil
64
+ let onMove: ((IndexSet, Int) -> Void)? = movable
65
+ ? { offsets, destination in
66
+ // `to` is the index among the container's children before which
67
+ // the row goes, counted before it leaves, like `move(fromOffsets:toOffset:)`.
68
+ let base = children.count - rows.count
69
+ for offset in offsets where rows.indices.contains(offset) {
70
+ app?.dispatch(.move(rows[offset].id, to: base + destination))
71
+ }
72
+ }
73
+ : nil
74
+ ForEach(children.filter { $0.kind == "section" }) { section in
75
+ SectionElement(node: section)
76
+ }
77
+ ForEach(rows) { row in
78
+ ElementView(node: row)
79
+ .moveDisabled(row.kind == "listrow" ? !ListRowProps(props: row.props).movable : true)
80
+ .deleteDisabled(row.kind == "listrow" ? !ListRowProps(props: row.props).deletable : true)
81
+ }
82
+ .onDelete(perform: onDelete)
83
+ .onMove(perform: onMove)
84
+ }
85
+ }
86
+
87
+ /// `section`: header and footer are strings; `sectionheader`/`sectionfooter`
88
+ /// children are view headers.
89
+ struct SectionElement: View {
90
+ let node: ElementNode
91
+
92
+ var body: some View {
93
+ let props = SectionProps(props: node.props)
94
+ let header = node.children.first { $0.kind == "sectionheader" }
95
+ let footer = node.children.first { $0.kind == "sectionfooter" }
96
+ let rows = node.children.filter { $0.kind != "sectionheader" && $0.kind != "sectionfooter" }
97
+ let title = props.header.flatMap { $0.isEmpty ? nil : $0 }
98
+ let note = props.footer.flatMap { $0.isEmpty ? nil : $0 }
99
+ if header != nil || footer != nil {
100
+ // View headers and footers (`sectionheader` / `sectionfooter` children).
101
+ Section {
102
+ ListRowsView(children: rows)
103
+ } header: {
104
+ if let header {
105
+ ChildrenView(node: header)
106
+ } else if let title {
107
+ Text(verbatim: title)
108
+ }
109
+ } footer: {
110
+ if let footer {
111
+ ChildrenView(node: footer)
112
+ } else if let note {
113
+ Text(verbatim: note)
114
+ }
115
+ }
116
+ } else if let title, let note {
117
+ // String headers and footers are `Text`s, as `Section("…")` makes them.
118
+ Section { ListRowsView(children: rows) } header: { Text(verbatim: title) } footer: { Text(verbatim: note) }
119
+ } else if let title {
120
+ Section(title) { ListRowsView(children: rows) }
121
+ } else if let note {
122
+ Section { ListRowsView(children: rows) } footer: { Text(verbatim: note) }
123
+ } else {
124
+ Section { ListRowsView(children: rows) }
125
+ }
126
+ }
127
+ }
128
+
129
+ /// `listrow`: the row's content with its edit-mode hints; a tap on a
130
+ /// selectable row sends `tap`.
131
+ struct ListRowElement: View {
132
+ let node: ElementNode
133
+ @Environment(\.nativeApp) private var app
134
+
135
+ var body: some View {
136
+ let props = ListRowProps(props: node.props)
137
+ // A row's children are one row: a stack when there are several.
138
+ let content: AnyView = node.children.count == 1 ? AnyView(ElementView(node: node.children[0])) : AnyView(HStack(spacing: 0) { ChildrenView(node: node) })
139
+ if props.selectable {
140
+ let tappable = content
141
+ .contentShape(Rectangle())
142
+ .onTapGesture { app?.dispatch(.tap(node.id)) }
143
+ if props.selected {
144
+ tappable.listRowBackground(Color.accentColor.opacity(0.15))
145
+ } else {
146
+ tappable
147
+ }
148
+ } else {
149
+ content
150
+ }
151
+ }
152
+ }
153
+
154
+ /// `.swipeActions`: children are `[content, ...trailing actions, ...leading actions]`
155
+ /// (`trailing` and `leading` count them).
156
+ struct SwipeActionsElement: View {
157
+ let node: ElementNode
158
+
159
+ var body: some View {
160
+ if let content = node.children.first {
161
+ let trailingCount = node.props.int("trailing") ?? 0
162
+ let actions = Array(node.children.dropFirst())
163
+ let trailing = Array(actions.prefix(trailingCount))
164
+ let leading = Array(actions.dropFirst(trailingCount))
165
+ ElementView(node: content)
166
+ .swipeActions(edge: .trailing, allowsFullSwipe: node.props.bool("fullSwipeTrailing") ?? false) {
167
+ ForEach(trailing) { ElementView(node: $0) }
168
+ }
169
+ .swipeActions(edge: .leading, allowsFullSwipe: node.props.bool("fullSwipeLeading") ?? false) {
170
+ ForEach(leading) { ElementView(node: $0) }
171
+ }
172
+ }
173
+ }
174
+ }
175
+ #endif