@swiftbrowser/native 0.18.2 → 0.19.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 +19 -0
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/bridge.d.ts +34 -7
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/encoding.d.ts +13 -0
- package/dist/encoding.d.ts.map +1 -1
- package/dist/encoding.js +24 -0
- package/dist/encoding.js.map +1 -1
- package/dist/fetch.d.ts +10 -4
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +19 -11
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +22 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +65 -13
- package/dist/hostRequests.js.map +1 -1
- package/dist/ios.d.ts +79 -0
- package/dist/ios.d.ts.map +1 -0
- package/dist/ios.js +181 -0
- package/dist/ios.js.map +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +16 -7
- package/dist/runtime.js.map +1 -1
- package/dist/subtle.d.ts +1 -1
- package/dist/subtle.d.ts.map +1 -1
- package/dist/subtle.js +9 -5
- package/dist/subtle.js.map +1 -1
- package/dist/webSocket.d.ts +12 -5
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +24 -10
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/bridge.ts +30 -7
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +18 -13
- package/src/hostRequests.ts +72 -19
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +27 -12
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -0
- package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
- package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +81 -20
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
|
@@ -7,9 +7,10 @@ import SwiftUI
|
|
|
7
7
|
/// `navstack`: the children are `navscreen`s, bottom to top. The protocol
|
|
8
8
|
/// expresses push and pop purely as children appearing and disappearing;
|
|
9
9
|
/// natively the path is derived from them. A native pop (the back button, an
|
|
10
|
-
/// edge swipe) sends `tap` with the popped screen's id, as the protocol says
|
|
11
|
-
///
|
|
12
|
-
///
|
|
10
|
+
/// edge swipe) sends `tap` with the popped screen's id, as the protocol says
|
|
11
|
+
/// (PopTap.swift: at once for a button, once the finger lets go for a
|
|
12
|
+
/// swipe), and the app removes the child, so the next read of the path
|
|
13
|
+
/// agrees with the screen.
|
|
13
14
|
struct NavigationStackElement: View {
|
|
14
15
|
let node: ElementNode
|
|
15
16
|
@Environment(\.nativeApp) private var app
|
|
@@ -29,9 +30,10 @@ struct NavigationStackElement: View {
|
|
|
29
30
|
let path = Binding<[Int]>(
|
|
30
31
|
get: { node?.children.filter { $0.kind == "navscreen" }.dropFirst().map(\.id) ?? [] },
|
|
31
32
|
set: { next in
|
|
32
|
-
// Only pops come from here (pushes come from the app
|
|
33
|
+
// Only pops come from here (pushes come from the app; a
|
|
34
|
+
// cancelled swipe puts its id back, which the getter kept).
|
|
33
35
|
for id in pushed where !next.contains(id) {
|
|
34
|
-
|
|
36
|
+
PopTap.send(id, app: app)
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
)
|
|
@@ -43,10 +45,14 @@ struct NavigationStackElement: View {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
.navigationDestination(for: Int.self) { id in
|
|
46
|
-
|
|
48
|
+
// A screen the app has popped already (on the tap a back
|
|
49
|
+
// swipe or button sent, before the pop's transition has
|
|
50
|
+
// run) is still drawn as it was, from the tree's record
|
|
51
|
+
// of it, so the pop slides it away rather than dropping
|
|
52
|
+
// its content in one frame.
|
|
53
|
+
if let screen = node?.children.first(where: { $0.id == id }) ?? app?.tree.poppedScreen(id) {
|
|
47
54
|
NavigationScreenElement(node: screen)
|
|
48
55
|
} else {
|
|
49
|
-
// Popped by the app already; SwiftUI is on its way out.
|
|
50
56
|
Color.clear
|
|
51
57
|
}
|
|
52
58
|
}
|
|
@@ -106,6 +112,16 @@ struct NavigationSplitElement: View {
|
|
|
106
112
|
struct NavigationScreenElement: View {
|
|
107
113
|
let node: ElementNode
|
|
108
114
|
@Environment(\.nativeApp) private var app
|
|
115
|
+
/// The bar's items as the push began, shown until it settles (`held`
|
|
116
|
+
/// below): iOS sizes an item's glass as a push starts and lays it out
|
|
117
|
+
/// again only once the push ends, so an item that changes meanwhile (an
|
|
118
|
+
/// Edit button arriving with the rows) would sit clipped in the old shape
|
|
119
|
+
/// until then. Held, the change shows after the push, laid out whole.
|
|
120
|
+
@State private var held: [ToolbarSnapshot]?
|
|
121
|
+
@State private var settled = false
|
|
122
|
+
|
|
123
|
+
/// How long a push takes, with a margin.
|
|
124
|
+
private static let pushSettles: Duration = .milliseconds(600)
|
|
109
125
|
|
|
110
126
|
var body: some View {
|
|
111
127
|
let props = NavScreenProps(props: node.props)
|
|
@@ -122,10 +138,22 @@ struct NavigationScreenElement: View {
|
|
|
122
138
|
let content = ZStack {
|
|
123
139
|
ForEach(node.children(except: ["toolbar", "searchfield"])) { ElementView(node: $0) }
|
|
124
140
|
}
|
|
141
|
+
let live = toolbars.map(ToolbarSnapshot.init)
|
|
142
|
+
let shown = props.depth > 0 && !settled ? (held ?? live) : live
|
|
125
143
|
let titled = content
|
|
126
144
|
.modifier(KeyboardBarInset(toolbars: keyboardToolbars))
|
|
127
145
|
.navigationTitle(props.title)
|
|
128
|
-
.toolbar { ScreenToolbar(toolbars:
|
|
146
|
+
.toolbar { ScreenToolbar(toolbars: shown) }
|
|
147
|
+
.onAppear {
|
|
148
|
+
// A pushed screen, appearing: its bar as it is now, until the push has settled.
|
|
149
|
+
guard props.depth > 0, !settled, held == nil else { return }
|
|
150
|
+
held = live
|
|
151
|
+
Task { @MainActor in
|
|
152
|
+
try? await Task.sleep(for: Self.pushSettles)
|
|
153
|
+
settled = true
|
|
154
|
+
held = nil
|
|
155
|
+
}
|
|
156
|
+
}
|
|
129
157
|
let searched: AnyView = {
|
|
130
158
|
guard let search else { return AnyView(titled) }
|
|
131
159
|
let searchProps = SearchFieldProps(props: search.props)
|
|
@@ -135,8 +163,13 @@ struct NavigationScreenElement: View {
|
|
|
135
163
|
))
|
|
136
164
|
}()
|
|
137
165
|
#if os(iOS) || canImport(FoundationBridge)
|
|
166
|
+
// `.toolbar(.hidden, for:)` and `.statusBarHidden`: SwiftUI hides the
|
|
167
|
+
// bars (animated in an animated commit); `.automatic` leaves a shown one as it is.
|
|
138
168
|
let modeTitled = searched
|
|
139
169
|
.navigationBarTitleDisplayMode(props.displayMode == "inline" ? .inline : props.displayMode == "large" ? .large : .automatic)
|
|
170
|
+
.toolbar(props.navigationBarHidden ? .hidden : .automatic, for: .navigationBar)
|
|
171
|
+
.toolbar(props.bottomBarHidden ? .hidden : .automatic, for: .bottomBar)
|
|
172
|
+
.statusBarHidden(props.statusBarHidden)
|
|
140
173
|
#if canImport(FoundationBridge)
|
|
141
174
|
modeTitled // the shim has no `navigationBarBackButtonHidden` (nor does the protocol carry it)
|
|
142
175
|
#else
|
|
@@ -188,8 +221,19 @@ struct NavigationScreenElement: View {
|
|
|
188
221
|
/// group (and share the bar's glass) as the app's do. A toolbar content
|
|
189
222
|
/// builder takes no loop, so the elements go in by index, eight to a chunk
|
|
190
223
|
/// and the rest in the next.
|
|
224
|
+
/// A `toolbar` element with the items it had at one moment (`NavigationScreenElement.held`), or has now.
|
|
225
|
+
struct ToolbarSnapshot {
|
|
226
|
+
let node: ElementNode
|
|
227
|
+
let items: [ElementNode]
|
|
228
|
+
|
|
229
|
+
init(node: ElementNode) {
|
|
230
|
+
self.node = node
|
|
231
|
+
self.items = node.children
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
191
235
|
struct ScreenToolbar: ToolbarContent {
|
|
192
|
-
let toolbars: [
|
|
236
|
+
let toolbars: [ToolbarSnapshot]
|
|
193
237
|
var from = 0
|
|
194
238
|
|
|
195
239
|
var body: some ToolbarContent {
|
|
@@ -209,7 +253,7 @@ struct ScreenToolbar: ToolbarContent {
|
|
|
209
253
|
@ToolbarContentBuilder
|
|
210
254
|
private func item(_ offset: Int) -> some ToolbarContent {
|
|
211
255
|
if from + offset < toolbars.count {
|
|
212
|
-
ToolbarElement(
|
|
256
|
+
ToolbarElement(toolbar: toolbars[from + offset])
|
|
213
257
|
}
|
|
214
258
|
}
|
|
215
259
|
}
|
|
@@ -221,9 +265,10 @@ struct ScreenToolbar: ToolbarContent {
|
|
|
221
265
|
/// `ToolbarItem` iOS 26 shows only the first), without the bar's shared
|
|
222
266
|
/// glass when `sharedBackground` is `hidden` (iOS 26).
|
|
223
267
|
struct ToolbarElement: ToolbarContent {
|
|
224
|
-
let
|
|
268
|
+
let toolbar: ToolbarSnapshot
|
|
225
269
|
|
|
226
270
|
var body: some ToolbarContent {
|
|
271
|
+
let node = toolbar.node
|
|
227
272
|
let props = ToolbarProps(props: node.props)
|
|
228
273
|
let placement = NavigationScreenElement.placement(props.placement)
|
|
229
274
|
if let spacer = props.spacer {
|
|
@@ -234,12 +279,12 @@ struct ToolbarElement: ToolbarContent {
|
|
|
234
279
|
}
|
|
235
280
|
} else if props.sharedBackground == "hidden" {
|
|
236
281
|
if #available(iOS 26, macOS 26, *) {
|
|
237
|
-
ToolbarItemGroup(placement: placement) {
|
|
282
|
+
ToolbarItemGroup(placement: placement) { ForEach(toolbar.items) { ElementView(node: $0) } }.sharedBackgroundVisibility(.hidden)
|
|
238
283
|
} else {
|
|
239
|
-
ToolbarItemGroup(placement: placement) {
|
|
284
|
+
ToolbarItemGroup(placement: placement) { ForEach(toolbar.items) { ElementView(node: $0) } }
|
|
240
285
|
}
|
|
241
286
|
} else {
|
|
242
|
-
ToolbarItemGroup(placement: placement) {
|
|
287
|
+
ToolbarItemGroup(placement: placement) { ForEach(toolbar.items) { ElementView(node: $0) } }
|
|
243
288
|
}
|
|
244
289
|
}
|
|
245
290
|
}
|
|
@@ -250,6 +295,10 @@ struct ToolbarElement: ToolbarContent {
|
|
|
250
295
|
struct NavigationLinkElement: View {
|
|
251
296
|
let node: ElementNode
|
|
252
297
|
@Environment(\.nativeApp) private var app
|
|
298
|
+
@Environment(\.listSelectsRows) private var listSelects
|
|
299
|
+
#if os(iOS) || canImport(FoundationBridge)
|
|
300
|
+
@Environment(\.editMode) private var editMode
|
|
301
|
+
#endif
|
|
253
302
|
|
|
254
303
|
/// The wrappers a row's link may sit in and still be the row (the web
|
|
255
304
|
/// engine's transparent kinds): any other kind between it and the list
|
|
@@ -267,12 +316,21 @@ struct NavigationLinkElement: View {
|
|
|
267
316
|
return false
|
|
268
317
|
}
|
|
269
318
|
|
|
319
|
+
/// Whether the list around is in edit mode with a selection: a link row
|
|
320
|
+
/// is then selected by its tap (the row's `tag`, ListRowElement), not
|
|
321
|
+
/// followed, as iOS selects a `NavigationLink` row then.
|
|
322
|
+
private var selecting: Bool {
|
|
323
|
+
#if os(iOS) || canImport(FoundationBridge)
|
|
324
|
+
listSelects && editMode?.wrappedValue.isEditing == true
|
|
325
|
+
#else
|
|
326
|
+
false
|
|
327
|
+
#endif
|
|
328
|
+
}
|
|
329
|
+
|
|
270
330
|
var body: some View {
|
|
271
331
|
let props = NavLinkProps(props: node.props)
|
|
272
332
|
let inList = Self.isListRow(node)
|
|
273
|
-
|
|
274
|
-
app?.dispatch(.tap(node.id))
|
|
275
|
-
} label: {
|
|
333
|
+
let label = Group {
|
|
276
334
|
if inList {
|
|
277
335
|
// The disclosure chevron in the trailing 22 points, as measured
|
|
278
336
|
// against iOS's row: 13 pt semibold, 2 in from the content's edge
|
|
@@ -291,9 +349,12 @@ struct NavigationLinkElement: View {
|
|
|
291
349
|
ChildrenView(node: node)
|
|
292
350
|
}
|
|
293
351
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
352
|
+
let tappable: AnyView = inList && selecting
|
|
353
|
+
? AnyView(label)
|
|
354
|
+
: AnyView(Button { app?.dispatch(.tap(node.id)) } label: { label }.buttonStyle(.plain))
|
|
355
|
+
tappable
|
|
356
|
+
.foregroundStyle(inList || props.plain ? AnyShapeStyle(HierarchicalShapeStyle.primary) : AnyShapeStyle(TintShapeStyle()))
|
|
357
|
+
.listRowBackground(props.selected ? Color.accentColor.opacity(0.15) : nil)
|
|
297
358
|
}
|
|
298
359
|
}
|
|
299
360
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftUI
|
|
4
|
+
|
|
5
|
+
/// A pinch taken from the fingers themselves (iOS 18's `SpatialEventGesture`,
|
|
6
|
+
/// Views/Kinds/More.swift), as the web renderer takes one from two pointers
|
|
7
|
+
/// (Web/src/pinch.ts): the scale is the distance between the first two
|
|
8
|
+
/// fingers over their distance when the second came down, the rotation the
|
|
9
|
+
/// turn of the line between them (unwrapped past a half turn), the location
|
|
10
|
+
/// their midpoint. The pinch begins the moment the scale has moved
|
|
11
|
+
/// `minimumScaleDelta` or the angle `minimumAngleDelta` (with no minimum, on
|
|
12
|
+
/// the first move), where SwiftUI's `MagnifyGesture` waits for the system's
|
|
13
|
+
/// recognizer to make up its mind, a good way into the spread. A third
|
|
14
|
+
/// finger waits; lifting one of the pair hands the pinch to it, the scale
|
|
15
|
+
/// unbroken, and lifting the last two ends it. A report that leaves a finger
|
|
16
|
+
/// out keeps it where it was. A reference, so a move redraws nothing.
|
|
17
|
+
final class PinchTracker {
|
|
18
|
+
struct Report: Equatable {
|
|
19
|
+
var phase: String
|
|
20
|
+
var scale: Double
|
|
21
|
+
/// Degrees.
|
|
22
|
+
var rotation: Double
|
|
23
|
+
var scaleVelocity: Double
|
|
24
|
+
var rotationVelocity: Double
|
|
25
|
+
var startLocation: CGPoint
|
|
26
|
+
var location: CGPoint
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// The window the velocities are taken over, as the web renderer's.
|
|
30
|
+
static let velocityWindow: TimeInterval = 0.1
|
|
31
|
+
|
|
32
|
+
let magnify: Bool
|
|
33
|
+
let rotate: Bool
|
|
34
|
+
let minimumScaleDelta: Double
|
|
35
|
+
/// Degrees.
|
|
36
|
+
let minimumAngleDelta: Double
|
|
37
|
+
|
|
38
|
+
private struct Base {
|
|
39
|
+
var a: AnyHashable
|
|
40
|
+
var b: AnyHashable
|
|
41
|
+
var distance: Double
|
|
42
|
+
var angle: Double
|
|
43
|
+
var midpoint: CGPoint
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// A finger on the element: where it was last reported.
|
|
47
|
+
struct Finger: Equatable {
|
|
48
|
+
var id: AnyHashable
|
|
49
|
+
var location: CGPoint
|
|
50
|
+
/// `active`, or `ended` / `cancelled` as the system reported it.
|
|
51
|
+
var active: Bool
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private var base: Base?
|
|
55
|
+
/// The fingers down, in order of arrival, at their last reported location.
|
|
56
|
+
private var fingers: [(id: AnyHashable, location: CGPoint)] = []
|
|
57
|
+
private var began = false
|
|
58
|
+
private var scale = 1.0
|
|
59
|
+
private var rotation = 0.0
|
|
60
|
+
private var lastAngle = 0.0
|
|
61
|
+
private var location = CGPoint.zero
|
|
62
|
+
private var samples: [(t: TimeInterval, scale: Double, rotation: Double)] = []
|
|
63
|
+
private var reported: Report?
|
|
64
|
+
|
|
65
|
+
init(magnify: Bool, rotate: Bool, minimumScaleDelta: Double, minimumAngleDelta: Double) {
|
|
66
|
+
self.magnify = magnify
|
|
67
|
+
self.rotate = rotate
|
|
68
|
+
self.minimumScaleDelta = max(0, minimumScaleDelta)
|
|
69
|
+
self.minimumAngleDelta = max(0, minimumAngleDelta)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Whether two fingers are being followed (a pinch, begun or not).
|
|
73
|
+
var tracking: Bool { base != nil }
|
|
74
|
+
|
|
75
|
+
/// Takes the fingers now down (in the element's coordinates) at `time`;
|
|
76
|
+
/// what to send, or nil when there is nothing to say yet.
|
|
77
|
+
func update(active: [(id: AnyHashable, location: CGPoint)], cancelled: Bool, at time: TimeInterval) -> Report? {
|
|
78
|
+
update(fingers: active.map { Finger(id: $0.id, location: $0.location, active: true) }, cancelled: cancelled, at: time)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Takes the fingers the system reported at `time`: a finger it reports
|
|
82
|
+
/// as ended or cancelled has lifted, one it leaves out is where it was
|
|
83
|
+
/// (a report need not name every finger). What to send, or nil when
|
|
84
|
+
/// there is nothing to say yet.
|
|
85
|
+
func update(fingers reports: [Finger], cancelled: Bool, at time: TimeInterval) -> Report? {
|
|
86
|
+
for finger in reports {
|
|
87
|
+
if !finger.active {
|
|
88
|
+
fingers.removeAll { $0.id == finger.id }
|
|
89
|
+
} else if let index = fingers.firstIndex(where: { $0.id == finger.id }) {
|
|
90
|
+
fingers[index].location = finger.location
|
|
91
|
+
} else {
|
|
92
|
+
fingers.append((finger.id, finger.location))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
guard let base else {
|
|
96
|
+
guard fingers.count >= 2 else { return nil }
|
|
97
|
+
let a = fingers[0]
|
|
98
|
+
let b = fingers[1]
|
|
99
|
+
let distance = max(Self.distance(a.location, b.location), 1)
|
|
100
|
+
let angle = Self.angle(a.location, b.location)
|
|
101
|
+
let midpoint = Self.midpoint(a.location, b.location)
|
|
102
|
+
self.base = Base(a: a.id, b: b.id, distance: distance, angle: angle, midpoint: midpoint)
|
|
103
|
+
scale = 1
|
|
104
|
+
rotation = 0
|
|
105
|
+
lastAngle = angle
|
|
106
|
+
location = midpoint
|
|
107
|
+
samples = [(time, 1, 0)]
|
|
108
|
+
return nil
|
|
109
|
+
}
|
|
110
|
+
var a = fingers.first { $0.id == base.a }?.location
|
|
111
|
+
var b = fingers.first { $0.id == base.b }?.location
|
|
112
|
+
if a == nil || b == nil {
|
|
113
|
+
// A finger of the pair lifted. With two fingers still down (the
|
|
114
|
+
// system gave one a new identity, or a third was waiting) the
|
|
115
|
+
// pinch carries on from where it is, its scale unbroken.
|
|
116
|
+
guard fingers.count >= 2 else { return end(cancelled: cancelled) }
|
|
117
|
+
let next = (fingers[0], fingers[1])
|
|
118
|
+
let distance = Self.distance(next.0.location, next.1.location)
|
|
119
|
+
let angle = Self.angle(next.0.location, next.1.location)
|
|
120
|
+
self.base = Base(a: next.0.id, b: next.1.id, distance: max(distance / max(scale, 0.0001), 1), angle: base.angle, midpoint: base.midpoint)
|
|
121
|
+
lastAngle = angle
|
|
122
|
+
a = next.0.location
|
|
123
|
+
b = next.1.location
|
|
124
|
+
}
|
|
125
|
+
guard let a, let b, let base = self.base else { return nil }
|
|
126
|
+
scale = Self.distance(a, b) / base.distance
|
|
127
|
+
let angle = Self.angle(a, b)
|
|
128
|
+
var delta = angle - lastAngle
|
|
129
|
+
if delta > 180 { delta -= 360 } else if delta <= -180 { delta += 360 }
|
|
130
|
+
rotation += delta
|
|
131
|
+
lastAngle = angle
|
|
132
|
+
location = Self.midpoint(a, b)
|
|
133
|
+
samples.append((time, scale, rotation))
|
|
134
|
+
while samples.count > 2, let first = samples.first, time - first.t > Self.velocityWindow { samples.removeFirst() }
|
|
135
|
+
if !began {
|
|
136
|
+
let scaled = magnify && abs(scale - 1) >= minimumScaleDelta
|
|
137
|
+
let turned = rotate && abs(rotation) >= minimumAngleDelta
|
|
138
|
+
guard scaled || turned else { return nil }
|
|
139
|
+
began = true
|
|
140
|
+
return report("began")
|
|
141
|
+
}
|
|
142
|
+
let previous = reported
|
|
143
|
+
let next = report("changed")
|
|
144
|
+
if let previous, previous.scale == next.scale, previous.rotation == next.rotation, previous.location == next.location { return nil }
|
|
145
|
+
return next
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Every finger up (or the gesture cancelled): the end of a pinch that began.
|
|
149
|
+
func end(cancelled: Bool) -> Report? {
|
|
150
|
+
defer {
|
|
151
|
+
base = nil
|
|
152
|
+
fingers = []
|
|
153
|
+
began = false
|
|
154
|
+
samples = []
|
|
155
|
+
reported = nil
|
|
156
|
+
}
|
|
157
|
+
guard began else { return nil }
|
|
158
|
+
return report(cancelled ? "cancelled" : "ended")
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private func report(_ phase: String) -> Report {
|
|
162
|
+
let first = samples.first
|
|
163
|
+
let last = samples.last
|
|
164
|
+
let dt = first.map { f in (last?.t ?? f.t) - f.t } ?? 0
|
|
165
|
+
let scaleVelocity = first != nil && last != nil && dt > 0 ? (last!.scale - first!.scale) / dt : 0
|
|
166
|
+
let rotationVelocity = first != nil && last != nil && dt > 0 ? (last!.rotation - first!.rotation) / dt : 0
|
|
167
|
+
let out = Report(
|
|
168
|
+
phase: phase,
|
|
169
|
+
scale: Self.round(scale, 4),
|
|
170
|
+
rotation: Self.round(rotation, 2),
|
|
171
|
+
scaleVelocity: Self.round(scaleVelocity, 4),
|
|
172
|
+
rotationVelocity: Self.round(rotationVelocity, 2),
|
|
173
|
+
startLocation: base?.midpoint ?? .zero,
|
|
174
|
+
location: location
|
|
175
|
+
)
|
|
176
|
+
reported = out
|
|
177
|
+
return out
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private static func distance(_ a: CGPoint, _ b: CGPoint) -> Double {
|
|
181
|
+
let dx = Double(b.x - a.x)
|
|
182
|
+
let dy = Double(b.y - a.y)
|
|
183
|
+
return (dx * dx + dy * dy).squareRoot()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Degrees, as `atan2` gives them (counterclockwise on screen is negative, as the web's).
|
|
187
|
+
private static func angle(_ a: CGPoint, _ b: CGPoint) -> Double {
|
|
188
|
+
atan2(Double(b.y - a.y), Double(b.x - a.x)) * 180 / .pi
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private static func midpoint(_ a: CGPoint, _ b: CGPoint) -> CGPoint {
|
|
192
|
+
CGPoint(x: (a.x + b.x) / 2, y: (a.y + b.y) / 2)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private static func round(_ value: Double, _ places: Int) -> Double {
|
|
196
|
+
let factor = pow(10.0, Double(places))
|
|
197
|
+
return (value * factor).rounded() / factor
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
#endif
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftUI
|
|
4
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
5
|
+
import UIKit
|
|
6
|
+
#endif
|
|
7
|
+
|
|
8
|
+
/// The `tap` a native pop sends with the popped screen's id (Navigation.swift),
|
|
9
|
+
/// timed to the pop. For a back button or a programmatic pop it goes at once:
|
|
10
|
+
/// the app removes the screen inside SwiftUI's set of the path, and the stack
|
|
11
|
+
/// keeps drawing it from the tree's record through the transition
|
|
12
|
+
/// (`OpTree.poppedScreen`). For an interactive pop (the edge swipe) SwiftUI
|
|
13
|
+
/// sets the path the moment the swipe begins, before anyone knows whether
|
|
14
|
+
/// it will go through; the tap waits until the finger has let go and the pop
|
|
15
|
+
/// is finishing, and a cancelled swipe sends none. Sent at the swipe's start,
|
|
16
|
+
/// the app's removal left the screen drawn from the record with its title
|
|
17
|
+
/// gone while the finger was still down, and a cancelled swipe showing a
|
|
18
|
+
/// screen the app no longer had.
|
|
19
|
+
enum PopTap {
|
|
20
|
+
@MainActor
|
|
21
|
+
static func send(_ id: Int, app: NativeApp?) {
|
|
22
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
23
|
+
if let coordinator = interactivePop() {
|
|
24
|
+
send(id, during: CoordinatorTransition(coordinator: coordinator)) { app?.dispatch(.tap($0)) }
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
#endif
|
|
28
|
+
app?.dispatch(.tap(id))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The tap through the pop's transition: at once unless a finger drives
|
|
32
|
+
/// it, else when the finger lets go, and only when the pop goes through.
|
|
33
|
+
@MainActor
|
|
34
|
+
static func send(_ id: Int, during transition: any PopTransition, dispatch: @escaping @MainActor (Int) -> Void) {
|
|
35
|
+
guard transition.isInteractive else {
|
|
36
|
+
dispatch(id)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
transition.whenInteractionEnds { cancelled in
|
|
40
|
+
if !cancelled { dispatch(id) }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
45
|
+
/// The transition of a navigation controller in the app's windows that a finger is driving, if any.
|
|
46
|
+
@MainActor
|
|
47
|
+
private static func interactivePop() -> (any UIViewControllerTransitionCoordinator)? {
|
|
48
|
+
let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows)
|
|
49
|
+
for window in windows {
|
|
50
|
+
for nav in navigationControllers(in: window) {
|
|
51
|
+
if let coordinator = nav.transitionCoordinator, coordinator.isInteractive { return coordinator }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return nil
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// The navigation controllers behind the views in `window`, by their responder chains.
|
|
58
|
+
@MainActor
|
|
59
|
+
static func navigationControllers(in window: UIWindow) -> [UINavigationController] {
|
|
60
|
+
var found: [UINavigationController] = []
|
|
61
|
+
func visit(_ view: UIView) {
|
|
62
|
+
var responder: UIResponder? = view.next
|
|
63
|
+
while let r = responder {
|
|
64
|
+
if let nav = r as? UINavigationController, !found.contains(where: { $0 === nav }) { found.append(nav) }
|
|
65
|
+
responder = r.next
|
|
66
|
+
}
|
|
67
|
+
for subview in view.subviews { visit(subview) }
|
|
68
|
+
}
|
|
69
|
+
visit(window)
|
|
70
|
+
return found
|
|
71
|
+
}
|
|
72
|
+
#endif
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// A pop's transition as `PopTap` reads it: whether a finger drives it, and
|
|
76
|
+
/// word of the finger letting go (UIKit's transition coordinator, or a test's).
|
|
77
|
+
@MainActor
|
|
78
|
+
protocol PopTransition {
|
|
79
|
+
var isInteractive: Bool { get }
|
|
80
|
+
/// Calls `handler` once the interactive part ends, with whether the pop was cancelled.
|
|
81
|
+
func whenInteractionEnds(_ handler: @escaping @MainActor (_ cancelled: Bool) -> Void)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
85
|
+
/// UIKit's transition coordinator as a `PopTransition`.
|
|
86
|
+
@MainActor
|
|
87
|
+
struct CoordinatorTransition: PopTransition {
|
|
88
|
+
let coordinator: any UIViewControllerTransitionCoordinator
|
|
89
|
+
|
|
90
|
+
var isInteractive: Bool { coordinator.isInteractive }
|
|
91
|
+
|
|
92
|
+
func whenInteractionEnds(_ handler: @escaping @MainActor (_ cancelled: Bool) -> Void) {
|
|
93
|
+
coordinator.notifyWhenInteractionChanges { context in
|
|
94
|
+
let cancelled = context.isCancelled
|
|
95
|
+
MainActor.assumeIsolated { handler(cancelled) }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
#endif
|
|
100
|
+
#endif
|
|
@@ -204,6 +204,7 @@ struct ScrollViewElement: View {
|
|
|
204
204
|
let scroll = ScrollView(axes, showsIndicators: props.showsIndicators) {
|
|
205
205
|
ChildrenView(node: node)
|
|
206
206
|
}
|
|
207
|
+
.scrollDismissesKeyboard(props.dismissesKeyboard == "interactively" ? .interactively : props.dismissesKeyboard == "immediately" ? .immediately : .automatic)
|
|
207
208
|
let anchored = anchored(AnyView(scroll), props: props)
|
|
208
209
|
let refreshed: AnyView = props.refreshable ? AnyView(anchored.refreshable { app?.dispatch(.refresh(node.id)) }) : anchored
|
|
209
210
|
let snapped: AnyView = props.snap == "paging" ? AnyView(refreshed.scrollTargetBehavior(.paging))
|
|
@@ -382,7 +383,7 @@ struct ScrollViewElement: View {
|
|
|
382
383
|
var order: [Int: Int] = [:]
|
|
383
384
|
func visit(_ element: ElementNode) {
|
|
384
385
|
for child in element.children {
|
|
385
|
-
|
|
386
|
+
guard child.kind != "scrollview", child.kind != "list" else { continue }
|
|
386
387
|
if child.kind == "styled", child.style.scrollID { order[child.id] = order.count }
|
|
387
388
|
visit(child)
|
|
388
389
|
}
|
|
@@ -404,7 +405,7 @@ struct ScrollViewElement: View {
|
|
|
404
405
|
var best: ScrollRequest?
|
|
405
406
|
func visit(_ element: ElementNode) {
|
|
406
407
|
for child in element.children {
|
|
407
|
-
|
|
408
|
+
guard child.kind != "scrollview", child.kind != "list" else { continue }
|
|
408
409
|
if child.kind == "styled", let request = child.style.scrollTo, best == nil || request.serial > best!.serial {
|
|
409
410
|
best = ScrollRequest(serial: request.serial, id: child.id, anchor: request.anchor.map(UnitPoint.init(spec:)), axes: request.axes)
|
|
410
411
|
}
|
|
@@ -53,6 +53,10 @@ extension View {
|
|
|
53
53
|
if let tint = style.tint { view = AnyView(view.tint(Color(spec: tint))) }
|
|
54
54
|
// `.scrollDisabled`: the scroll views and text editors below (a text editor then as tall as its lines).
|
|
55
55
|
if let disabled = style.raw.bool("scrollDisabled") { view = AnyView(view.scrollDisabled(disabled)) }
|
|
56
|
+
// `.textSelection(.enabled)`: the text below can be selected.
|
|
57
|
+
if let selectable = style.raw.bool("textSelection") {
|
|
58
|
+
view = selectable ? AnyView(view.textSelection(.enabled)) : AnyView(view.textSelection(.disabled))
|
|
59
|
+
}
|
|
56
60
|
// `labelIcon` marks a `Label`'s icon; HStackElement draws the label as one.
|
|
57
61
|
if let alignment = style.raw.string("gridColumnAlignment") { view = AnyView(view.gridColumnAlignment(HorizontalAlignment(name: alignment))) }
|
|
58
62
|
|
|
@@ -132,6 +132,8 @@ public struct ScrollViewProps: Equatable, Sendable {
|
|
|
132
132
|
public var scrollOffset: ScrollOffsetSpec?
|
|
133
133
|
/// `.defaultScrollAnchor(_:)`: where the content starts, and where it stays as it grows.
|
|
134
134
|
public var defaultScrollAnchor: UnitPointSpec?
|
|
135
|
+
/// `.scrollDismissesKeyboard(_:)`: `immediately` or `interactively`; nil leaves the keyboard be.
|
|
136
|
+
public var dismissesKeyboard: String?
|
|
135
137
|
public init(props: JSONObject) {
|
|
136
138
|
axes = props.string("axes") ?? "vertical"
|
|
137
139
|
showsIndicators = props.bool("showsIndicators") ?? true
|
|
@@ -142,6 +144,7 @@ public struct ScrollViewProps: Equatable, Sendable {
|
|
|
142
144
|
reportsGeometry = props.bool("reportsGeometry") ?? false
|
|
143
145
|
scrollOffset = ScrollOffsetSpec(json: props.value("scrollOffset"))
|
|
144
146
|
defaultScrollAnchor = UnitPointSpec(json: props.value("defaultScrollAnchor"))
|
|
147
|
+
dismissesKeyboard = props.bool("dismissesKeyboard") == true ? "immediately" : props.string("dismissesKeyboard")
|
|
145
148
|
}
|
|
146
149
|
}
|
|
147
150
|
|
|
@@ -183,9 +186,22 @@ public struct ListProps: Equatable, Sendable {
|
|
|
183
186
|
public struct SectionProps: Equatable, Sendable {
|
|
184
187
|
public var header: String?
|
|
185
188
|
public var footer: String?
|
|
189
|
+
/// `Section(isExpanded:)`: the section collapses (in a sidebar-style list); a `tap` toggles it.
|
|
190
|
+
public var collapsible: Bool
|
|
191
|
+
public var expanded: Bool
|
|
186
192
|
public init(props: JSONObject) {
|
|
187
193
|
header = props.string("header")
|
|
188
194
|
footer = props.string("footer")
|
|
195
|
+
collapsible = props.bool("collapsible") ?? false
|
|
196
|
+
expanded = props.bool("expanded") ?? true
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// `disclosuregroup`: open or not; its children are the label (first) and the content; a `tap` toggles it.
|
|
201
|
+
public struct DisclosureGroupProps: Equatable, Sendable {
|
|
202
|
+
public var expanded: Bool
|
|
203
|
+
public init(props: JSONObject) {
|
|
204
|
+
expanded = props.bool("expanded") ?? false
|
|
189
205
|
}
|
|
190
206
|
}
|
|
191
207
|
|
|
@@ -284,12 +300,19 @@ public struct NavScreenProps: Equatable, Sendable {
|
|
|
284
300
|
public var depth: Int
|
|
285
301
|
public var toolbarRole: String?
|
|
286
302
|
public var backButtonHidden: Bool
|
|
303
|
+
/// `.toolbar(.hidden, for: .navigationBar)` / `.bottomBar`, and `.statusBarHidden()`.
|
|
304
|
+
public var navigationBarHidden: Bool
|
|
305
|
+
public var bottomBarHidden: Bool
|
|
306
|
+
public var statusBarHidden: Bool
|
|
287
307
|
public init(props: JSONObject) {
|
|
288
308
|
title = props.string("title") ?? ""
|
|
289
309
|
displayMode = props.string("displayMode") ?? "automatic"
|
|
290
310
|
depth = props.int("depth") ?? 0
|
|
291
311
|
toolbarRole = props.string("toolbarRole")
|
|
292
312
|
backButtonHidden = props.bool("backButtonHidden") ?? false
|
|
313
|
+
navigationBarHidden = props.bool("navigationBarHidden") ?? false
|
|
314
|
+
bottomBarHidden = props.bool("bottomBarHidden") ?? false
|
|
315
|
+
statusBarHidden = props.bool("statusBarHidden") ?? false
|
|
293
316
|
}
|
|
294
317
|
}
|
|
295
318
|
|
|
@@ -532,11 +555,14 @@ public struct SliderProps: Equatable, Sendable {
|
|
|
532
555
|
public var min: Double
|
|
533
556
|
public var max: Double
|
|
534
557
|
public var step: Double?
|
|
558
|
+
/// What each child is, in order: `label`, `min` (the minimum value's label), `max`.
|
|
559
|
+
public var labels: [String]
|
|
535
560
|
public init(props: JSONObject) {
|
|
536
561
|
value = props.double("value") ?? 0
|
|
537
562
|
min = props.double("min") ?? 0
|
|
538
563
|
max = props.double("max") ?? 1
|
|
539
564
|
step = props.double("step")
|
|
565
|
+
labels = props.array("labels")?.compactMap(\.string) ?? []
|
|
540
566
|
}
|
|
541
567
|
}
|
|
542
568
|
|
|
@@ -26,6 +26,13 @@ public final class OpTree {
|
|
|
26
26
|
/// The root container, element id 0.
|
|
27
27
|
public let root: ElementNode
|
|
28
28
|
private var nodes: [Int: ElementNode]
|
|
29
|
+
/// Screens (`navscreen`) removed lately, with when: a navigation stack
|
|
30
|
+
/// keeps drawing one through the pop that takes it off (SwiftUI asks
|
|
31
|
+
/// the destination for it while the transition runs; the app has
|
|
32
|
+
/// removed it by then, synchronously on the pop's tap).
|
|
33
|
+
private var poppedScreens: [Int: (node: ElementNode, at: Date)] = [:]
|
|
34
|
+
/// How long a popped screen stays available: past any pop transition.
|
|
35
|
+
private static let poppedScreenLife: TimeInterval = 3
|
|
29
36
|
/// The ops of the current pass, until its `commit`.
|
|
30
37
|
private var pending: [Op] = []
|
|
31
38
|
/// How many passes were committed.
|
|
@@ -57,6 +64,18 @@ public final class OpTree {
|
|
|
57
64
|
/// The element with this id, when it exists (created and not removed).
|
|
58
65
|
public func node(_ id: Int) -> ElementNode? { nodes[id] }
|
|
59
66
|
|
|
67
|
+
/// A `navscreen` removed within the last moments (`poppedScreenLife`),
|
|
68
|
+
/// its subtree as it was: what a stack shows while the pop that removed
|
|
69
|
+
/// it is still animating. Nil once it is forgotten or its id reused.
|
|
70
|
+
public func poppedScreen(_ id: Int) -> ElementNode? {
|
|
71
|
+
guard let entry = poppedScreens[id] else { return nil }
|
|
72
|
+
guard Date().timeIntervalSince(entry.at) < Self.poppedScreenLife else {
|
|
73
|
+
poppedScreens[id] = nil
|
|
74
|
+
return nil
|
|
75
|
+
}
|
|
76
|
+
return entry.node
|
|
77
|
+
}
|
|
78
|
+
|
|
60
79
|
/// How many elements exist, the root included.
|
|
61
80
|
public var count: Int { nodes.count }
|
|
62
81
|
|
|
@@ -126,6 +145,7 @@ public final class OpTree {
|
|
|
126
145
|
// for a live id replaces it in place (the web renderer does the same).
|
|
127
146
|
detach(existing)
|
|
128
147
|
}
|
|
148
|
+
poppedScreens[id] = nil
|
|
129
149
|
nodes[id] = ElementNode(id: id, kind: kind, props: props)
|
|
130
150
|
case .update(let id, let props):
|
|
131
151
|
guard let node = nodes[id] else { throw OpTreeError("update: no element \(id)") }
|
|
@@ -155,6 +175,11 @@ public final class OpTree {
|
|
|
155
175
|
guard let node = nodes[id] else { throw OpTreeError("remove: no element \(id)") }
|
|
156
176
|
detach(node)
|
|
157
177
|
forget(node)
|
|
178
|
+
if node.kind == "navscreen" {
|
|
179
|
+
let now = Date()
|
|
180
|
+
poppedScreens = poppedScreens.filter { now.timeIntervalSince($0.value.at) < Self.poppedScreenLife }
|
|
181
|
+
poppedScreens[id] = (node, now)
|
|
182
|
+
}
|
|
158
183
|
case .commit:
|
|
159
184
|
break
|
|
160
185
|
case .other(let name, let fields):
|