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