@swiftbrowser/native 0.17.7 → 0.18.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 +99 -11
- package/dist/blob.d.ts +60 -0
- package/dist/blob.d.ts.map +1 -0
- package/dist/blob.js +153 -0
- package/dist/blob.js.map +1 -0
- package/dist/bridge.d.ts +65 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/fetch.d.ts +11 -3
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +26 -2
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +55 -0
- package/dist/hostRequests.d.ts.map +1 -0
- package/dist/hostRequests.js +196 -0
- package/dist/hostRequests.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/matchMedia.d.ts +43 -0
- package/dist/matchMedia.d.ts.map +1 -0
- package/dist/matchMedia.js +63 -0
- package/dist/matchMedia.js.map +1 -0
- package/dist/notifications.d.ts +108 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +251 -0
- package/dist/notifications.js.map +1 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +23 -2
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +69 -0
- package/dist/runtime.js.map +1 -1
- package/dist/secureStorage.d.ts +20 -0
- package/dist/secureStorage.d.ts.map +1 -0
- package/dist/secureStorage.js +28 -0
- package/dist/secureStorage.js.map +1 -0
- package/dist/site.d.ts +3 -3
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +1 -1
- package/dist/site.js.map +1 -1
- package/dist/subtle.d.ts +38 -0
- package/dist/subtle.d.ts.map +1 -0
- package/dist/subtle.js +72 -0
- package/dist/subtle.js.map +1 -0
- package/dist/undo.d.ts +23 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +44 -0
- package/dist/undo.js.map +1 -0
- package/dist/webSocket.d.ts +73 -0
- package/dist/webSocket.d.ts.map +1 -0
- package/dist/webSocket.js +145 -0
- package/dist/webSocket.js.map +1 -0
- package/package.json +3 -3
- package/src/blob.ts +186 -0
- package/src/bridge.ts +74 -1
- package/src/fetch.ts +29 -3
- package/src/hostRequests.ts +216 -0
- package/src/index.ts +8 -0
- package/src/matchMedia.ts +79 -0
- package/src/notifications.ts +317 -0
- package/src/run.ts +21 -2
- package/src/runtime.ts +71 -0
- package/src/secureStorage.ts +39 -0
- package/src/site.ts +3 -3
- package/src/subtle.ts +87 -0
- package/src/undo.ts +47 -0
- package/src/webSocket.ts +175 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +211 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +451 -0
- package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
- package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
- package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +56 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +362 -16
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
- package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
- package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
- package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
- package/wasm/SwiftBrowserNative.wasm +2 -2
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import SwiftBrowserProtocol
|
|
3
|
+
import SwiftBrowserTree
|
|
4
|
+
import SwiftUI
|
|
5
|
+
|
|
6
|
+
/// A screen's `keyboard` toolbars (`ToolbarItemGroup(placement: .keyboard)`)
|
|
7
|
+
/// as a bottom safe-area inset of its content, drawn by `KeyboardBar` on
|
|
8
|
+
/// iOS; elsewhere, and against the shim, the content is left as it is and
|
|
9
|
+
/// the toolbars go to SwiftUI's own placement.
|
|
10
|
+
struct KeyboardBarInset: ViewModifier {
|
|
11
|
+
let toolbars: [ElementNode]
|
|
12
|
+
|
|
13
|
+
func body(content: Content) -> some View {
|
|
14
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
15
|
+
if toolbars.contains(where: { ToolbarProps(props: $0.props).spacer == nil }) {
|
|
16
|
+
content.safeAreaInset(edge: .bottom, spacing: 0) { KeyboardBar(toolbars: toolbars) }
|
|
17
|
+
} else {
|
|
18
|
+
content
|
|
19
|
+
}
|
|
20
|
+
#else
|
|
21
|
+
content
|
|
22
|
+
#endif
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
27
|
+
import Combine
|
|
28
|
+
import UIKit
|
|
29
|
+
|
|
30
|
+
/// The bar above the keyboard as Notes draws its format bar on iOS 26: a
|
|
31
|
+
/// glass capsule 48 tall, inset 10 from the screen's sides, floating 10
|
|
32
|
+
/// above the keyboard, there while the keyboard is up and gone with it.
|
|
33
|
+
/// SwiftUI's `.keyboard` placement is the input accessory view, a bar flush
|
|
34
|
+
/// with the keyboard across the screen, which the system's apps no longer
|
|
35
|
+
/// show. The items are the screen's `keyboard` toolbars' children in one
|
|
36
|
+
/// row, a `spacer` toolbar a `Spacer` (8 wide when fixed), as the page lays
|
|
37
|
+
/// them out (docs/ops-protocol.md, "The keyboard toolbar"). As a safe-area
|
|
38
|
+
/// inset of the content, the bar keeps the content above it, as the
|
|
39
|
+
/// keyboard does; with the keyboard down it has no height.
|
|
40
|
+
struct KeyboardBar: View {
|
|
41
|
+
let toolbars: [ElementNode]
|
|
42
|
+
@State private var shown = false
|
|
43
|
+
|
|
44
|
+
var body: some View {
|
|
45
|
+
Group {
|
|
46
|
+
if shown {
|
|
47
|
+
HStack(spacing: 0) {
|
|
48
|
+
ForEach(toolbars) { toolbar in
|
|
49
|
+
if let spacer = ToolbarProps(props: toolbar.props).spacer {
|
|
50
|
+
if spacer == "fixed" { Spacer().frame(width: 8) } else { Spacer(minLength: 0) }
|
|
51
|
+
} else {
|
|
52
|
+
ChildrenView(node: toolbar)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
.padding(.horizontal, 16)
|
|
57
|
+
.frame(maxWidth: .infinity, minHeight: 48)
|
|
58
|
+
.modifier(CapsuleGlass())
|
|
59
|
+
.padding(.horizontal, 10)
|
|
60
|
+
.padding(.bottom, 10)
|
|
61
|
+
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
|
|
65
|
+
withAnimation(.easeOut(duration: 0.25)) { shown = true }
|
|
66
|
+
}
|
|
67
|
+
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
|
|
68
|
+
withAnimation(.easeOut(duration: 0.25)) { shown = false }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Liquid Glass from iOS 26; a material before it.
|
|
74
|
+
private struct CapsuleGlass: ViewModifier {
|
|
75
|
+
func body(content: Content) -> some View {
|
|
76
|
+
if #available(iOS 26, *) {
|
|
77
|
+
content.glassEffect(.regular, in: .capsule)
|
|
78
|
+
} else {
|
|
79
|
+
content.background(.regularMaterial, in: Capsule())
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
#endif
|
|
84
|
+
#endif
|
|
@@ -77,19 +77,28 @@ struct DatePickerElement: View {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/// `gesture`: one child; a drag sends `drag` events through its phases
|
|
81
|
-
///
|
|
80
|
+
/// `gesture`: one child; a drag sends `drag` events through its phases, a
|
|
81
|
+
/// long press sends `longpress`, and two fingers spreading (`magnify`) or
|
|
82
|
+
/// turning (`rotate`) send `pinch` events. The pinches go alongside the
|
|
83
|
+
/// view's other gestures (`simultaneousGesture`), so a canvas's one-finger
|
|
84
|
+
/// pan inside a two-finger zoom does not keep the zoom from starting. The
|
|
85
|
+
/// drag and the long press attach as `mode` says (`.gesture`,
|
|
86
|
+
/// `.highPriorityGesture` or `.simultaneousGesture`), and the drag's points
|
|
87
|
+
/// are in `coordinateSpace` (`.local` or `.global`).
|
|
82
88
|
struct GestureElement: View {
|
|
83
89
|
let node: ElementNode
|
|
84
90
|
@Environment(\.nativeApp) private var app
|
|
85
91
|
@State private var dragging = false
|
|
92
|
+
@State private var magnifying = false
|
|
93
|
+
@State private var rotating = false
|
|
94
|
+
@State private var fingers = Fingers()
|
|
86
95
|
|
|
87
96
|
var body: some View {
|
|
88
97
|
let props = GestureProps(props: node.props)
|
|
89
|
-
let content = ChildrenView(node: node).contentShape(Rectangle())
|
|
98
|
+
let content = AnyView(ChildrenView(node: node).contentShape(Rectangle()))
|
|
90
99
|
let dragged: AnyView = props.drag
|
|
91
|
-
?
|
|
92
|
-
DragGesture(minimumDistance: props.minimumDistance)
|
|
100
|
+
? attach(
|
|
101
|
+
DragGesture(minimumDistance: props.minimumDistance, coordinateSpace: props.coordinateSpace == "global" ? .global : .local)
|
|
93
102
|
.onChanged { value in
|
|
94
103
|
let phase = dragging ? "changed" : "began"
|
|
95
104
|
dragging = true
|
|
@@ -98,18 +107,99 @@ struct GestureElement: View {
|
|
|
98
107
|
.onEnded { value in
|
|
99
108
|
dragging = false
|
|
100
109
|
send(value, phase: "ended")
|
|
110
|
+
},
|
|
111
|
+
to: content, mode: props.mode
|
|
112
|
+
)
|
|
113
|
+
: content
|
|
114
|
+
let pressed: AnyView = props.longPress
|
|
115
|
+
? attach(
|
|
116
|
+
LongPressGesture(minimumDuration: props.minimumDuration).onEnded { _ in app?.dispatch(.longPress(node.id)) },
|
|
117
|
+
to: dragged, mode: props.mode
|
|
118
|
+
)
|
|
119
|
+
: dragged
|
|
120
|
+
let tracked = props.magnify || props.rotate ? trackingFingers(pressed) : pressed
|
|
121
|
+
let magnified: AnyView = props.magnify
|
|
122
|
+
? AnyView(tracked.simultaneousGesture(
|
|
123
|
+
MagnifyGesture(minimumScaleDelta: props.minimumScaleDelta)
|
|
124
|
+
.onChanged { value in
|
|
125
|
+
let phase = magnifying ? "changed" : "began"
|
|
126
|
+
magnifying = true
|
|
127
|
+
sendPinch(phase: phase, scale: value.magnification, rotation: 0, scaleVelocity: value.velocity, rotationVelocity: 0, startLocation: value.startLocation, startAnchor: value.startAnchor)
|
|
128
|
+
}
|
|
129
|
+
.onEnded { value in
|
|
130
|
+
magnifying = false
|
|
131
|
+
sendPinch(phase: "ended", scale: value.magnification, rotation: 0, scaleVelocity: value.velocity, rotationVelocity: 0, startLocation: value.startLocation, startAnchor: value.startAnchor)
|
|
101
132
|
}
|
|
102
133
|
))
|
|
103
|
-
:
|
|
104
|
-
if props.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
134
|
+
: tracked
|
|
135
|
+
if props.rotate {
|
|
136
|
+
magnified.simultaneousGesture(
|
|
137
|
+
RotateGesture(minimumAngleDelta: .degrees(props.minimumAngleDelta))
|
|
138
|
+
.onChanged { value in
|
|
139
|
+
let phase = rotating ? "changed" : "began"
|
|
140
|
+
rotating = true
|
|
141
|
+
sendPinch(phase: phase, scale: 1, rotation: value.rotation.degrees, scaleVelocity: 0, rotationVelocity: value.velocity.degrees, startLocation: value.startLocation, startAnchor: value.startAnchor)
|
|
142
|
+
}
|
|
143
|
+
.onEnded { value in
|
|
144
|
+
rotating = false
|
|
145
|
+
sendPinch(phase: "ended", scale: 1, rotation: value.rotation.degrees, scaleVelocity: 0, rotationVelocity: value.velocity.degrees, startLocation: value.startLocation, startAnchor: value.startAnchor)
|
|
146
|
+
}
|
|
147
|
+
)
|
|
108
148
|
} else {
|
|
109
|
-
|
|
149
|
+
magnified
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// The gesture on the view as `mode` composes it with the view's others and its subviews'.
|
|
154
|
+
private func attach<G: Gesture>(_ gesture: G, to view: AnyView, mode: String?) -> AnyView {
|
|
155
|
+
switch mode {
|
|
156
|
+
case "simultaneous": AnyView(view.simultaneousGesture(gesture))
|
|
157
|
+
case "highPriority": AnyView(view.highPriorityGesture(gesture))
|
|
158
|
+
default: AnyView(view.gesture(gesture))
|
|
110
159
|
}
|
|
111
160
|
}
|
|
112
161
|
|
|
162
|
+
private func sendPinch(phase: String, scale: CGFloat, rotation: Double, scaleVelocity: CGFloat, rotationVelocity: Double, startLocation: CGPoint, startAnchor: UnitPoint) {
|
|
163
|
+
let pinch = Fingers.Pinch(scale: scale, rotation: rotation, scaleVelocity: scaleVelocity, rotationVelocity: rotationVelocity, startLocation: startLocation, startAnchor: startAnchor)
|
|
164
|
+
fingers.pinch = phase == "ended" ? nil : pinch
|
|
165
|
+
sendPinch(pinch, phase: phase)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private func sendPinch(_ pinch: Fingers.Pinch, phase: String) {
|
|
169
|
+
app?.dispatch(.pinch(
|
|
170
|
+
node.id, phase: phase, scale: pinch.scale, rotation: pinch.rotation, scaleVelocity: pinch.scaleVelocity, rotationVelocity: pinch.rotationVelocity,
|
|
171
|
+
startLocation: (pinch.startLocation.x, pinch.startLocation.y), startAnchor: (pinch.startAnchor.x, pinch.startAnchor.y),
|
|
172
|
+
location: fingers.midpoint.map { ($0.x, $0.y) }
|
|
173
|
+
))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// The fingers on the element, for the pinch's `location`: SwiftUI's
|
|
177
|
+
/// `MagnifyGesture` and `RotateGesture` tell where the fingers began, not
|
|
178
|
+
/// where they are. A move of the fingers alone (two fingers panning)
|
|
179
|
+
/// sends the pinch again with the new midpoint. Before iOS 18 there is no
|
|
180
|
+
/// `SpatialEventGesture`, and the pinch goes without a location.
|
|
181
|
+
private func trackingFingers(_ view: AnyView) -> AnyView {
|
|
182
|
+
// Apple's SwiftUI only: the shim the package also builds against has no spatial events.
|
|
183
|
+
#if os(iOS) || os(macOS)
|
|
184
|
+
guard #available(iOS 18.0, macOS 15.0, *) else { return view }
|
|
185
|
+
let fingers = fingers
|
|
186
|
+
return AnyView(view.simultaneousGesture(
|
|
187
|
+
SpatialEventGesture()
|
|
188
|
+
.onChanged { events in
|
|
189
|
+
let down = events.filter { $0.phase == .active }.map(\.location)
|
|
190
|
+
guard down.count >= 2 else { return }
|
|
191
|
+
let midpoint = CGPoint(x: down.map(\.x).reduce(0, +) / CGFloat(down.count), y: down.map(\.y).reduce(0, +) / CGFloat(down.count))
|
|
192
|
+
guard midpoint != fingers.midpoint else { return }
|
|
193
|
+
fingers.midpoint = midpoint
|
|
194
|
+
if let pinch = fingers.pinch { sendPinch(pinch, phase: "changed") }
|
|
195
|
+
}
|
|
196
|
+
.onEnded { _ in fingers.midpoint = nil }
|
|
197
|
+
))
|
|
198
|
+
#else
|
|
199
|
+
return view
|
|
200
|
+
#endif
|
|
201
|
+
}
|
|
202
|
+
|
|
113
203
|
private func send(_ value: DragGesture.Value, phase: String) {
|
|
114
204
|
app?.dispatch(.drag(
|
|
115
205
|
node.id, phase: phase,
|
|
@@ -121,6 +211,21 @@ struct GestureElement: View {
|
|
|
121
211
|
}
|
|
122
212
|
}
|
|
123
213
|
|
|
214
|
+
/// A pinch element's fingers: their midpoint while two are down, and the
|
|
215
|
+
/// pinch they drive. A reference, so a move of the fingers does not redraw.
|
|
216
|
+
final class Fingers {
|
|
217
|
+
struct Pinch {
|
|
218
|
+
var scale: CGFloat
|
|
219
|
+
var rotation: Double
|
|
220
|
+
var scaleVelocity: CGFloat
|
|
221
|
+
var rotationVelocity: Double
|
|
222
|
+
var startLocation: CGPoint
|
|
223
|
+
var startAnchor: UnitPoint
|
|
224
|
+
}
|
|
225
|
+
var midpoint: CGPoint?
|
|
226
|
+
var pinch: Pinch?
|
|
227
|
+
}
|
|
228
|
+
|
|
124
229
|
/// `viewthatfits`: the children are the candidates, in order.
|
|
125
230
|
struct ViewThatFitsElement: View {
|
|
126
231
|
let node: ElementNode
|
|
@@ -109,12 +109,21 @@ struct NavigationScreenElement: View {
|
|
|
109
109
|
|
|
110
110
|
var body: some View {
|
|
111
111
|
let props = NavScreenProps(props: node.props)
|
|
112
|
-
let
|
|
112
|
+
let allToolbars = node.children.filter { $0.kind == "toolbar" }
|
|
113
|
+
// On iOS the `keyboard` toolbars are a bar of the content's (KeyboardBar.swift), not SwiftUI's placement.
|
|
114
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
115
|
+
let keyboardToolbars = allToolbars.filter { ToolbarProps(props: $0.props).placement == "keyboard" }
|
|
116
|
+
let toolbars = allToolbars.filter { ToolbarProps(props: $0.props).placement != "keyboard" }
|
|
117
|
+
#else
|
|
118
|
+
let keyboardToolbars: [ElementNode] = []
|
|
119
|
+
let toolbars = allToolbars
|
|
120
|
+
#endif
|
|
113
121
|
let search = node.children.first { $0.kind == "searchfield" }
|
|
114
122
|
let content = ZStack {
|
|
115
123
|
ForEach(node.children(except: ["toolbar", "searchfield"])) { ElementView(node: $0) }
|
|
116
124
|
}
|
|
117
125
|
let titled = content
|
|
126
|
+
.modifier(KeyboardBarInset(toolbars: keyboardToolbars))
|
|
118
127
|
.navigationTitle(props.title)
|
|
119
128
|
.toolbar { ScreenToolbar(toolbars: toolbars) }
|
|
120
129
|
let searched: AnyView = {
|
|
@@ -155,6 +164,8 @@ struct NavigationScreenElement: View {
|
|
|
155
164
|
case "principal": return .principal
|
|
156
165
|
case "secondary": return .secondaryAction
|
|
157
166
|
case "bottom": return bottomPlacement
|
|
167
|
+
// The bar above the keyboard while a text input of the screen is focused.
|
|
168
|
+
case "keyboard": return .keyboard
|
|
158
169
|
case "largeTitle":
|
|
159
170
|
// In the large title's place: the shim's placement, and SwiftUI's
|
|
160
171
|
// from iOS 26 (it stands in for `navigationTitle`'s text while the
|
|
@@ -29,7 +29,14 @@ struct ShapeElement: View {
|
|
|
29
29
|
shape.fill(fill)
|
|
30
30
|
}
|
|
31
31
|
if let stroke = props.stroke {
|
|
32
|
-
let style = StrokeStyle(
|
|
32
|
+
let style = StrokeStyle(
|
|
33
|
+
lineWidth: stroke.lineWidth,
|
|
34
|
+
lineCap: stroke.lineCap == "round" ? .round : stroke.lineCap == "square" ? .square : .butt,
|
|
35
|
+
lineJoin: stroke.lineJoin == "round" ? .round : stroke.lineJoin == "bevel" ? .bevel : .miter,
|
|
36
|
+
miterLimit: stroke.miterLimit,
|
|
37
|
+
dash: stroke.dash.map { CGFloat($0) },
|
|
38
|
+
dashPhase: stroke.dashPhase
|
|
39
|
+
)
|
|
33
40
|
let paint = stroke.style.map(AnyShapeStyle.init(spec:)) ?? AnyShapeStyle(ForegroundStyle())
|
|
34
41
|
if stroke.inset, props.trimFrom == nil, let insettable = props.insettableShape {
|
|
35
42
|
strokeBorder(insettable, paint, style)
|
|
@@ -72,23 +79,37 @@ extension AnyShape {
|
|
|
72
79
|
case "ellipse": self = AnyShape(Ellipse())
|
|
73
80
|
case "roundedRectangle", "containerRelative": self = AnyShape(RoundedRectangle(cornerRadius: spec.cornerRadius, style: .continuous))
|
|
74
81
|
case "path":
|
|
75
|
-
|
|
76
|
-
// The shim's `Path` does not parse SVG path data yet: nothing to draw.
|
|
77
|
-
self = AnyShape(NoPathShape())
|
|
78
|
-
#else
|
|
79
|
-
if let path = spec.path, !path.isEmpty, let parsed = Path(path) {
|
|
82
|
+
if let data = spec.path, !data.isEmpty, let parsed = Path(svg: data) {
|
|
80
83
|
self = AnyShape(PathShape(path: parsed, size: spec.size.map { CGSize(width: $0.width, height: $0.height) }))
|
|
81
84
|
} else {
|
|
82
|
-
// No path yet (the size is not reported): nothing to draw.
|
|
85
|
+
// No path yet (the size is not reported), or none to read: nothing to draw.
|
|
83
86
|
self = AnyShape(NoPathShape())
|
|
84
87
|
}
|
|
85
|
-
#endif
|
|
86
88
|
default: self = AnyShape(Rectangle())
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
extension Path {
|
|
94
|
+
/// A path from SVG path data (`SVGPath`): what the protocol carries. SwiftUI's
|
|
95
|
+
/// own `Path(_:)` reads only `Path.description`'s form, so it read none of it.
|
|
96
|
+
init?(svg data: String) {
|
|
97
|
+
guard let elements = SVGPath.parse(data) else { return nil }
|
|
98
|
+
func point(_ p: SVGPath.Point) -> CGPoint { CGPoint(x: p.x, y: p.y) }
|
|
99
|
+
self.init()
|
|
100
|
+
for element in elements {
|
|
101
|
+
switch element {
|
|
102
|
+
case .move(let to): move(to: point(to))
|
|
103
|
+
case .line(let to): addLine(to: point(to))
|
|
104
|
+
case .quadCurve(let to, let control): addQuadCurve(to: point(to), control: point(control))
|
|
105
|
+
case .curve(let to, let control1, let control2): addCurve(to: point(to), control1: point(control1), control2: point(control2))
|
|
106
|
+
case .closeSubpath: closeSubpath()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// A custom shape from SVG path data (`Path(svg:)` reads it). The app drew it
|
|
92
113
|
/// for the size the element reported (`size`); until the next report it is
|
|
93
114
|
/// stretched to the box, as the web renderer stretches its view box.
|
|
94
115
|
/// A shape with no path, for a custom shape before its first size report.
|
|
@@ -4,17 +4,33 @@ import SwiftBrowserProtocol
|
|
|
4
4
|
import SwiftBrowserTree
|
|
5
5
|
import SwiftUI
|
|
6
6
|
|
|
7
|
+
/// `vstack`; one with `pinned` is a `LazyVStack(pinnedViews:)`, whose `Section` children pin their headers.
|
|
7
8
|
struct VStackElement: View {
|
|
8
9
|
let node: ElementNode
|
|
9
10
|
var body: some View {
|
|
10
11
|
let props = StackProps(props: node.props)
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
if let pinned = pinnedViews(props.pinned) {
|
|
13
|
+
LazyVStack(alignment: HorizontalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }, pinnedViews: pinned) {
|
|
14
|
+
ChildrenView(node: node)
|
|
15
|
+
}
|
|
16
|
+
.scrollTargetLayout(when: props.scrollTarget)
|
|
17
|
+
} else {
|
|
18
|
+
VStack(alignment: HorizontalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
|
|
19
|
+
ChildrenView(node: node)
|
|
20
|
+
}
|
|
21
|
+
.scrollTargetLayout(when: props.scrollTarget)
|
|
13
22
|
}
|
|
14
|
-
.scrollTargetLayout(when: props.scrollTarget)
|
|
15
23
|
}
|
|
16
24
|
}
|
|
17
25
|
|
|
26
|
+
/// `pinnedViews` from the protocol's names (`sectionHeaders`, `sectionFooters`); nil when none.
|
|
27
|
+
func pinnedViews(_ names: [String]) -> PinnedScrollableViews? {
|
|
28
|
+
var views: PinnedScrollableViews = []
|
|
29
|
+
if names.contains("sectionHeaders") { views.insert(.sectionHeaders) }
|
|
30
|
+
if names.contains("sectionFooters") { views.insert(.sectionFooters) }
|
|
31
|
+
return views.isEmpty ? nil : views
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
/// `hstack`; a `Label` in its default style (the marked icon, `labelIcon` on
|
|
19
35
|
/// its `styled` wrapper, then the title) is a `Label`, so iOS lays it out and
|
|
20
36
|
/// colors it as one: the icon column and tint in a list row, the inherited
|
|
@@ -29,6 +45,11 @@ struct HStackElement: View {
|
|
|
29
45
|
} icon: {
|
|
30
46
|
ElementView(node: icon)
|
|
31
47
|
}
|
|
48
|
+
} else if let pinned = pinnedViews(props.pinned) {
|
|
49
|
+
LazyHStack(alignment: VerticalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }, pinnedViews: pinned) {
|
|
50
|
+
ChildrenView(node: node)
|
|
51
|
+
}
|
|
52
|
+
.scrollTargetLayout(when: props.scrollTarget)
|
|
32
53
|
} else {
|
|
33
54
|
HStack(alignment: VerticalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
|
|
34
55
|
ChildrenView(node: node)
|
|
@@ -97,7 +118,15 @@ struct GeometryElement: View {
|
|
|
97
118
|
let props = GeometryProps(props: node.props)
|
|
98
119
|
GeometryReader { proxy in
|
|
99
120
|
let frame = proxy.frame(in: .global)
|
|
100
|
-
|
|
121
|
+
// The report hangs off a clear view filling the reader, not off the
|
|
122
|
+
// children: a reader whose content draws nothing (a component that
|
|
123
|
+
// renders null) has no view for a modifier to run on, and never
|
|
124
|
+
// reported. The children sit at its top leading corner, as a
|
|
125
|
+
// reader's content does.
|
|
126
|
+
ZStack(alignment: .topLeading) {
|
|
127
|
+
Color.clear
|
|
128
|
+
ChildrenView(node: node)
|
|
129
|
+
}
|
|
101
130
|
.onChange(of: frame, initial: true) { _, frame in
|
|
102
131
|
report(frame, reportsFrame: props.reportsFrame)
|
|
103
132
|
}
|
|
@@ -132,6 +161,13 @@ struct GeometryElement: View {
|
|
|
132
161
|
/// time, on the marked element it names (`scrollID`, `.id(x)`): the view
|
|
133
162
|
/// finds the latest in its content and scrolls to it.
|
|
134
163
|
///
|
|
164
|
+
/// `reportsGeometry` (`.onScrollGeometryChange`, iOS 18) sends
|
|
165
|
+
/// `scrollgeometry` as the view scrolls and lays out; `scrollOffset`
|
|
166
|
+
/// (`ScrollPosition.scrollTo(x:y:)`, iOS 18) scrolls to a content offset
|
|
167
|
+
/// once per serial. A view with an offset command is positioned by a
|
|
168
|
+
/// `ScrollPosition` of its own and does not report ids (`reportsPosition`),
|
|
169
|
+
/// since SwiftUI takes one position per scroll view.
|
|
170
|
+
///
|
|
135
171
|
/// On iOS 18 and later the position is what the view settled on: the
|
|
136
172
|
/// visible target at the anchor once the scroll phase is idle, after a drag
|
|
137
173
|
/// or a `scrollTo` alike (so the app hears where a chevron's scroll ended),
|
|
@@ -148,6 +184,8 @@ struct ScrollViewElement: View {
|
|
|
148
184
|
@State private var idle = true
|
|
149
185
|
@State private var reported: Int?
|
|
150
186
|
@State private var appeared = false
|
|
187
|
+
/// The `scrollOffset` serial last carried out.
|
|
188
|
+
@State private var offsetServed: Double = -1
|
|
151
189
|
|
|
152
190
|
var body: some View {
|
|
153
191
|
let props = ScrollViewProps(props: node.props)
|
|
@@ -166,10 +204,13 @@ struct ScrollViewElement: View {
|
|
|
166
204
|
let scroll = ScrollView(axes, showsIndicators: props.showsIndicators) {
|
|
167
205
|
ChildrenView(node: node)
|
|
168
206
|
}
|
|
169
|
-
let
|
|
207
|
+
let anchored = anchored(AnyView(scroll), props: props)
|
|
208
|
+
let refreshed: AnyView = props.refreshable ? AnyView(anchored.refreshable { app?.dispatch(.refresh(node.id)) }) : anchored
|
|
170
209
|
let snapped: AnyView = props.snap == "paging" ? AnyView(refreshed.scrollTargetBehavior(.paging))
|
|
171
210
|
: props.snap == "viewAligned" ? AnyView(refreshed.scrollTargetBehavior(.viewAligned)) : refreshed
|
|
172
|
-
let positioned: AnyView = props.
|
|
211
|
+
let positioned: AnyView = props.scrollOffset != nil
|
|
212
|
+
? offsetPositioned(snapped, props: props)
|
|
213
|
+
: props.reportsPosition
|
|
173
214
|
? reportingSettled(AnyView(snapped.scrollPosition(id: Binding(get: { ahead ?? position }, set: { next in
|
|
174
215
|
guard next != position else { return }
|
|
175
216
|
position = next
|
|
@@ -177,7 +218,7 @@ struct ScrollViewElement: View {
|
|
|
177
218
|
if !Self.reportsSettled { report(next) }
|
|
178
219
|
}), anchor: anchor)), anchor: anchor)
|
|
179
220
|
: snapped
|
|
180
|
-
positioned
|
|
221
|
+
reportingGeometry(positioned, when: props.reportsGeometry)
|
|
181
222
|
.onAppear {
|
|
182
223
|
perform(request, props: props, with: proxy)
|
|
183
224
|
appeared = true
|
|
@@ -186,6 +227,22 @@ struct ScrollViewElement: View {
|
|
|
186
227
|
}
|
|
187
228
|
}
|
|
188
229
|
|
|
230
|
+
/// `defaultScrollAnchor`: the app's anchor, with all SwiftUI makes of it
|
|
231
|
+
/// (where the content starts, where it stays as it grows, and where
|
|
232
|
+
/// content smaller than the view sits). Without one, a view that scrolls
|
|
233
|
+
/// both ways keeps smaller content at its origin, as the page lays it out
|
|
234
|
+
/// (docs/ops-protocol.md, the `scrollview` layout row): SwiftUI centers it
|
|
235
|
+
/// along the vertical axis and reports the centering as a negative offset,
|
|
236
|
+
/// which put a short sheet in Numbers 92 points below its frozen header.
|
|
237
|
+
private func anchored(_ view: AnyView, props: ScrollViewProps) -> AnyView {
|
|
238
|
+
if let anchor = props.defaultScrollAnchor { return AnyView(view.defaultScrollAnchor(UnitPoint(spec: anchor))) }
|
|
239
|
+
guard props.axes == "both" else { return view }
|
|
240
|
+
#if !canImport(FoundationBridge)
|
|
241
|
+
if #available(iOS 18, macOS 15, *) { return AnyView(view.defaultScrollAnchor(.topLeading, for: .alignment)) }
|
|
242
|
+
#endif
|
|
243
|
+
return view
|
|
244
|
+
}
|
|
245
|
+
|
|
189
246
|
private func perform(_ request: ScrollRequest?, props: ScrollViewProps, with proxy: ScrollViewProxy) {
|
|
190
247
|
guard let request, request.serial > served else { return }
|
|
191
248
|
served = request.serial
|
|
@@ -274,6 +331,43 @@ struct ScrollViewElement: View {
|
|
|
274
331
|
return view
|
|
275
332
|
}
|
|
276
333
|
|
|
334
|
+
/// `.onScrollGeometryChange` (iOS 18): the offset, the content's and the
|
|
335
|
+
/// view's sizes and the insets, sent in the action itself. The app
|
|
336
|
+
/// answers synchronously with a pass, and what that pass moves (Numbers'
|
|
337
|
+
/// frozen headers, offset by the scroll) SwiftUI draws in the update the
|
|
338
|
+
/// scroll is drawn in, as a `@State` set in the action is: the frame the
|
|
339
|
+
/// web renderer gets by sending the geometry in the animation frame of
|
|
340
|
+
/// the scroll, before it paints. Deferred a turn (as GeometryElement's
|
|
341
|
+
/// reports are, measured during layout), the headers trailed the cells
|
|
342
|
+
/// by a frame.
|
|
343
|
+
private func reportingGeometry(_ view: AnyView, when reports: Bool) -> AnyView {
|
|
344
|
+
guard reports else { return view }
|
|
345
|
+
#if !canImport(FoundationBridge)
|
|
346
|
+
if #available(iOS 18, macOS 15, *) {
|
|
347
|
+
let app = app
|
|
348
|
+
let id = node.id
|
|
349
|
+
return AnyView(view.onScrollGeometryChange(for: ScrollGeometryRecord.self) { geometry in
|
|
350
|
+
ScrollGeometryRecord(geometry)
|
|
351
|
+
} action: { _, record in
|
|
352
|
+
app?.dispatch(record.event(id))
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
#endif
|
|
356
|
+
return view
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/// `scrollOffset` (iOS 18): a `ScrollPosition` of the view's own, told to
|
|
360
|
+
/// scroll to the command's point once per serial, from the view's
|
|
361
|
+
/// appearance on (the first pass has no size to scroll in).
|
|
362
|
+
private func offsetPositioned(_ view: AnyView, props: ScrollViewProps) -> AnyView {
|
|
363
|
+
#if !canImport(FoundationBridge)
|
|
364
|
+
if #available(iOS 18, macOS 15, *) {
|
|
365
|
+
return AnyView(OffsetPositioned(content: view, command: props.scrollOffset, served: $offsetServed))
|
|
366
|
+
}
|
|
367
|
+
#endif
|
|
368
|
+
return view
|
|
369
|
+
}
|
|
370
|
+
|
|
277
371
|
/// The target an anchor picks among the visible ones, in order: the first for a leading anchor (and none), the last for a trailing one, else the middle.
|
|
278
372
|
static func target(at anchor: UnitPoint?, among ids: [Int]) -> Int? {
|
|
279
373
|
guard !ids.isEmpty else { return nil }
|
|
@@ -322,14 +416,15 @@ struct ScrollViewElement: View {
|
|
|
322
416
|
}
|
|
323
417
|
}
|
|
324
418
|
|
|
325
|
-
/// `LazyVGrid`: `columns` are `GridItem`s; children are the cells
|
|
419
|
+
/// `LazyVGrid`: `columns` are `GridItem`s; children are the cells; `pinned` its `pinnedViews`.
|
|
326
420
|
struct LazyVGridElement: View {
|
|
327
421
|
let node: ElementNode
|
|
328
422
|
var body: some View {
|
|
329
423
|
LazyVGrid(
|
|
330
424
|
columns: gridItems(node.props.array("columns") ?? []),
|
|
331
425
|
alignment: HorizontalAlignment(name: node.props.string("alignment") ?? "center"),
|
|
332
|
-
spacing: node.props.double("spacing").map { CGFloat($0) }
|
|
426
|
+
spacing: node.props.double("spacing").map { CGFloat($0) },
|
|
427
|
+
pinnedViews: pinnedViews(node.props.array("pinned")?.compactMap(\.string) ?? []) ?? []
|
|
333
428
|
) {
|
|
334
429
|
ChildrenView(node: node)
|
|
335
430
|
}
|
|
@@ -342,7 +437,8 @@ struct LazyHGridElement: View {
|
|
|
342
437
|
LazyHGrid(
|
|
343
438
|
rows: gridItems(node.props.array("rows") ?? []),
|
|
344
439
|
alignment: VerticalAlignment(name: node.props.string("alignment") ?? "center"),
|
|
345
|
-
spacing: node.props.double("spacing").map { CGFloat($0) }
|
|
440
|
+
spacing: node.props.double("spacing").map { CGFloat($0) },
|
|
441
|
+
pinnedViews: pinnedViews(node.props.array("pinned")?.compactMap(\.string) ?? []) ?? []
|
|
346
442
|
) {
|
|
347
443
|
ChildrenView(node: node)
|
|
348
444
|
}
|
|
@@ -384,4 +480,61 @@ struct GridRowElement: View {
|
|
|
384
480
|
}
|
|
385
481
|
}
|
|
386
482
|
}
|
|
483
|
+
#if !canImport(FoundationBridge)
|
|
484
|
+
/// A `ScrollGeometry` as the protocol sends it; `Equatable` so the action hears real changes only.
|
|
485
|
+
@available(iOS 18, macOS 15, *)
|
|
486
|
+
struct ScrollGeometryRecord: Equatable {
|
|
487
|
+
var offset: CGPoint
|
|
488
|
+
var contentSize: CGSize
|
|
489
|
+
var containerSize: CGSize
|
|
490
|
+
var insets: EdgeInsets
|
|
491
|
+
|
|
492
|
+
init(_ geometry: ScrollGeometry) {
|
|
493
|
+
offset = geometry.contentOffset
|
|
494
|
+
contentSize = geometry.contentSize
|
|
495
|
+
containerSize = geometry.containerSize
|
|
496
|
+
insets = geometry.contentInsets
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
func event(_ id: Int) -> RendererEvent {
|
|
500
|
+
.scrollGeometry(
|
|
501
|
+
id, offset: (offset.x, offset.y), contentSize: (contentSize.width, contentSize.height),
|
|
502
|
+
containerSize: (containerSize.width, containerSize.height),
|
|
503
|
+
contentInsets: (insets.top, insets.leading, insets.bottom, insets.trailing)
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/// A scroll view positioned by a `ScrollPosition`, scrolled to each new `scrollOffset` command.
|
|
509
|
+
@available(iOS 18, macOS 15, *)
|
|
510
|
+
struct OffsetPositioned: View {
|
|
511
|
+
let content: AnyView
|
|
512
|
+
let command: ScrollOffsetSpec?
|
|
513
|
+
@Binding var served: Double
|
|
514
|
+
@State private var position = ScrollPosition(edge: .top)
|
|
515
|
+
@State private var appeared = false
|
|
516
|
+
|
|
517
|
+
var body: some View {
|
|
518
|
+
content
|
|
519
|
+
.scrollPosition($position)
|
|
520
|
+
.onAppear {
|
|
521
|
+
appeared = true
|
|
522
|
+
perform()
|
|
523
|
+
}
|
|
524
|
+
.onChange(of: command?.serial) { _, _ in perform() }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private func perform() {
|
|
528
|
+
guard appeared, let command, command.serial > served else { return }
|
|
529
|
+
served = command.serial
|
|
530
|
+
// `scrollTo` takes the axes it is given; an absent one keeps its offset.
|
|
531
|
+
switch (command.x.map { CGFloat($0) }, command.y.map { CGFloat($0) }) {
|
|
532
|
+
case let (x?, y?): position.scrollTo(x: x, y: y)
|
|
533
|
+
case let (x?, nil): position.scrollTo(x: x)
|
|
534
|
+
case let (nil, y?): position.scrollTo(y: y)
|
|
535
|
+
case (nil, nil): break
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
#endif
|
|
387
540
|
#endif
|
|
@@ -40,15 +40,28 @@ struct TextElement: View {
|
|
|
40
40
|
case .asset(let name, _): text = Text(Image(name))
|
|
41
41
|
default: text = Text(verbatim: "")
|
|
42
42
|
}
|
|
43
|
-
} else if
|
|
44
|
-
//
|
|
43
|
+
} else if run.link != nil || run.background != nil {
|
|
44
|
+
// A link run is tappable, a background run colored behind its
|
|
45
|
+
// glyphs: both are attributes of an `AttributedString`, which
|
|
46
|
+
// `Text` has and `Text`'s own modifiers do not.
|
|
45
47
|
var string = AttributedString(run.text ?? "")
|
|
46
|
-
string.link = url
|
|
48
|
+
if let link = run.link, let url = URL(string: link) { string.link = url }
|
|
49
|
+
if let background = run.background { string.backgroundColor = Color(spec: background) }
|
|
47
50
|
text = Text(string)
|
|
48
51
|
} else {
|
|
49
52
|
text = Text(verbatim: run.text ?? "")
|
|
50
53
|
}
|
|
51
|
-
if let font = run.font {
|
|
54
|
+
if let font = run.font {
|
|
55
|
+
if font.size == nil, font.textStyle == nil {
|
|
56
|
+
// Only a weight or design (a bold run): over the text's own
|
|
57
|
+
// font, as `StyleModifier` does, not the body font in it.
|
|
58
|
+
if let weight = font.weight { text = text.fontWeight(Font.Weight(name: weight)) }
|
|
59
|
+
if let design = font.design { text = text.fontDesign(Font.Design(name: design)) }
|
|
60
|
+
if font.monospacedDigit { text = text.monospacedDigit() }
|
|
61
|
+
} else {
|
|
62
|
+
text = text.font(Font(spec: font))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
52
65
|
if run.italic { text = text.italic() }
|
|
53
66
|
if run.underline { text = text.underline(true, color: run.underlineColor.map(Color.init(spec:))) }
|
|
54
67
|
if run.strikethrough { text = text.strikethrough(true, color: run.strikethroughColor.map(Color.init(spec:))) }
|