@swiftbrowser/native 0.18.3 → 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.
Files changed (73) hide show
  1. package/README.md +19 -0
  2. package/bin/swiftbrowser-native.mjs +47 -0
  3. package/dist/bridge.d.ts +34 -7
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js.map +1 -1
  6. package/dist/encoding.d.ts +13 -0
  7. package/dist/encoding.d.ts.map +1 -1
  8. package/dist/encoding.js +24 -0
  9. package/dist/encoding.js.map +1 -1
  10. package/dist/fetch.d.ts +10 -4
  11. package/dist/fetch.d.ts.map +1 -1
  12. package/dist/fetch.js +19 -11
  13. package/dist/fetch.js.map +1 -1
  14. package/dist/hostRequests.d.ts +22 -6
  15. package/dist/hostRequests.d.ts.map +1 -1
  16. package/dist/hostRequests.js +65 -13
  17. package/dist/hostRequests.js.map +1 -1
  18. package/dist/ios.d.ts +79 -0
  19. package/dist/ios.d.ts.map +1 -0
  20. package/dist/ios.js +181 -0
  21. package/dist/ios.js.map +1 -0
  22. package/dist/runtime.d.ts.map +1 -1
  23. package/dist/runtime.js +16 -7
  24. package/dist/runtime.js.map +1 -1
  25. package/dist/subtle.d.ts +1 -1
  26. package/dist/subtle.d.ts.map +1 -1
  27. package/dist/subtle.js +9 -5
  28. package/dist/subtle.js.map +1 -1
  29. package/dist/webSocket.d.ts +12 -5
  30. package/dist/webSocket.d.ts.map +1 -1
  31. package/dist/webSocket.js +24 -10
  32. package/dist/webSocket.js.map +1 -1
  33. package/package.json +7 -3
  34. package/src/bridge.ts +30 -7
  35. package/src/encoding.ts +22 -0
  36. package/src/fetch.ts +18 -13
  37. package/src/hostRequests.ts +72 -19
  38. package/src/ios.ts +253 -0
  39. package/src/runtime.ts +23 -8
  40. package/src/subtle.ts +10 -6
  41. package/src/webSocket.ts +27 -12
  42. package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
  43. package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
  44. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
  45. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
  46. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -0
  47. package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
  48. package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
  49. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
  50. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
  51. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
  52. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
  53. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
  54. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
  55. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
  56. package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
  57. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
  58. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
  59. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
  60. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
  61. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
  62. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
  63. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
  64. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
  65. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
  66. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +81 -20
  67. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
  68. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
  69. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
  70. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
  71. package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
  72. package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
  73. 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
- /// and the app removes the child in the same call, so the next read of the
12
- /// path already agrees with the screen.
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
- app?.dispatch(.tap(id))
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
- if let screen = node?.children.first(where: { $0.id == id }) {
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: 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: [ElementNode]
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(node: toolbars[from + offset])
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 node: ElementNode
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) { ChildrenView(node: node) }.sharedBackgroundVisibility(.hidden)
282
+ ToolbarItemGroup(placement: placement) { ForEach(toolbar.items) { ElementView(node: $0) } }.sharedBackgroundVisibility(.hidden)
238
283
  } else {
239
- ToolbarItemGroup(placement: placement) { ChildrenView(node: node) }
284
+ ToolbarItemGroup(placement: placement) { ForEach(toolbar.items) { ElementView(node: $0) } }
240
285
  }
241
286
  } else {
242
- ToolbarItemGroup(placement: placement) { ChildrenView(node: node) }
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
- Button {
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
- .buttonStyle(.plain)
295
- .foregroundStyle(inList || props.plain ? AnyShapeStyle(HierarchicalShapeStyle.primary) : AnyShapeStyle(TintShapeStyle()))
296
- .listRowBackground(props.selected ? Color.accentColor.opacity(0.15) : nil)
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
- if child.kind == "scrollview" || child.kind == "list" { continue }
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
- if child.kind == "scrollview" || child.kind == "list" { continue }
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):