@swiftbrowser/native 0.19.0 → 0.23.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 (66) hide show
  1. package/README.md +34 -11
  2. package/dist/blob.d.ts +85 -11
  3. package/dist/blob.d.ts.map +1 -1
  4. package/dist/blob.js +184 -34
  5. package/dist/blob.js.map +1 -1
  6. package/dist/bridge.d.ts +33 -2
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +19 -11
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +105 -41
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +3 -1
  14. package/dist/hostRequests.d.ts.map +1 -1
  15. package/dist/hostRequests.js +47 -6
  16. package/dist/hostRequests.js.map +1 -1
  17. package/dist/index.d.ts +1 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/runtime.js +1 -1
  22. package/dist/runtime.js.map +1 -1
  23. package/dist/webSocket.d.ts.map +1 -1
  24. package/dist/webSocket.js +13 -1
  25. package/dist/webSocket.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/blob.ts +215 -34
  28. package/src/bridge.ts +27 -2
  29. package/src/fetch.ts +99 -41
  30. package/src/hostRequests.ts +62 -8
  31. package/src/index.ts +1 -2
  32. package/src/runtime.ts +1 -1
  33. package/src/webSocket.ts +10 -1
  34. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +185 -24
  35. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
  36. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +2075 -213
  37. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +407 -16
  38. package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
  39. package/swift/Packages/SwiftBrowserShader/README.md +45 -0
  40. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
  41. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
  42. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
  43. package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
  44. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +141 -7
  45. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +47 -1
  46. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +98 -29
  47. package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
  48. package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
  49. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +11 -7
  50. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +28 -6
  51. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
  52. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +15 -10
  53. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +31 -28
  54. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +142 -0
  55. package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
  56. package/swift/Sources/SwiftBrowserProtocol/Props.swift +5 -2
  57. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
  58. package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
  59. package/swift/Sources/SwiftBrowserProtocol/TextChange.swift +17 -0
  60. package/wasm/SwiftBrowserNative.wasm +2 -2
  61. package/dist/native.d.ts +0 -19
  62. package/dist/native.d.ts.map +0 -1
  63. package/dist/native.js +0 -17
  64. package/dist/native.js.map +0 -1
  65. package/src/native.ts +0 -35
  66. package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +0 -53
@@ -224,7 +224,10 @@ struct TextEditorElement: View {
224
224
  #if !canImport(FoundationBridge)
225
225
  /// The plain `texteditor` with `selection` (iOS 18, `TextEditor(text:selection:)`):
226
226
  /// the viewer's selection reported in UTF-16 offsets, and the caret moved
227
- /// to one the app sets, as the rich editor does.
227
+ /// to one the app sets, as the rich editor does. A text that arrives from
228
+ /// outside (not the echo of what the viewer typed) keeps the caret: the
229
+ /// app's selection is applied even when its numbers did not change, else
230
+ /// the caret is moved past the change (`caretAfterChange`).
228
231
  @available(iOS 18, macOS 15, *)
229
232
  struct PlainTextEditorElement: View {
230
233
  let node: ElementNode
@@ -232,16 +235,22 @@ struct PlainTextEditorElement: View {
232
235
  @State private var selection: TextSelection?
233
236
  @State private var shown = Shown()
234
237
 
235
- /// The selection last sent, or last moved to (a class: set while drawing).
238
+ /// The selection last sent, or last moved to, and the text last sent (a class: set while drawing).
236
239
  final class Shown {
237
240
  var sent: TextSelectionSpec?
241
+ var sentText: String?
238
242
  }
239
243
 
240
244
  var body: some View {
241
245
  TextEditor(
242
246
  text: Binding(
243
247
  get: { node.props.string("text") ?? "" },
244
- set: { if let event = textChange(of: node, to: $0) { app?.dispatch(event) } }
248
+ set: { edited in
249
+ if let event = textChange(of: node, to: edited) {
250
+ shown.sentText = edited
251
+ app?.dispatch(event)
252
+ }
253
+ }
245
254
  ),
246
255
  selection: Binding(
247
256
  get: { selection },
@@ -256,12 +265,25 @@ struct PlainTextEditorElement: View {
256
265
  )
257
266
  )
258
267
  .onChange(of: TextEditorProps(props: node.props).selection) { _, wanted in move(to: wanted) }
268
+ .onChange(of: node.props.string("text") ?? "") { old, new in textArrived(from: old, to: new) }
259
269
  .onAppear { move(to: TextEditorProps(props: node.props).selection) }
260
270
  }
261
271
 
262
- /// A selection the app set (not the one this editor sent): the caret goes there.
263
- private func move(to wanted: TextSelectionSpec?) {
264
- guard let wanted, wanted != shown.sent else { return }
272
+ /// The app's text changed: the echo of what the viewer typed leaves the caret alone; a text from outside keeps it in place.
273
+ private func textArrived(from old: String, to new: String) {
274
+ if new == shown.sentText { return }
275
+ if let wanted = TextEditorProps(props: node.props).selection {
276
+ move(to: wanted, force: true)
277
+ } else if let current = selection, let range = offsets(of: current, in: old) {
278
+ let start = stringIndex(utf16: caretAfterChange(before: old, after: new, offset: range.start), in: new)
279
+ let end = stringIndex(utf16: caretAfterChange(before: old, after: new, offset: range.end), in: new)
280
+ selection = start == end ? TextSelection(insertionPoint: start) : TextSelection(range: start..<end)
281
+ }
282
+ }
283
+
284
+ /// A selection the app set (not the one this editor sent): the caret goes there; `force` moves even to the numbers last sent (the text changed under them).
285
+ private func move(to wanted: TextSelectionSpec?, force: Bool = false) {
286
+ guard let wanted, force || wanted != shown.sent else { return }
265
287
  shown.sent = wanted
266
288
  let text = node.props.string("text") ?? ""
267
289
  let start = stringIndex(utf16: wanted.start, in: text)
@@ -0,0 +1,378 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+ #if canImport(UniformTypeIdentifiers)
7
+ import UniformTypeIdentifiers
8
+ #endif
9
+
10
+ // The kinds that draw or lay out on the app's say: a `canvas` replaying the
11
+ // commands the app's renderer recorded, a custom `layout` answered by the
12
+ // app's JavaScript, and the drag and drop pair (docs/native.md, "Views").
13
+
14
+ // MARK: - canvas
15
+
16
+ /// `canvas`: SwiftUI's `Canvas`, replaying each command with the context's state as the app recorded
17
+ /// it: its clips (in the canvas's coordinates), then its transform, opacity, blend mode and filters.
18
+ /// It fills its proposal and clips the drawing to its box, as the page's does.
19
+ struct CanvasElement: View {
20
+ let node: ElementNode
21
+
22
+ var body: some View {
23
+ let props = CanvasProps(props: node.props)
24
+ Canvas { context, _ in
25
+ for command in props.commands { draw(command, into: context) }
26
+ }
27
+ .clipped()
28
+ }
29
+
30
+ private func draw(_ command: CanvasCommand, into canvas: GraphicsContext) {
31
+ var context = canvas
32
+ for clip in command.clips {
33
+ if let path = Path(svg: clip) { context.clip(to: path) }
34
+ }
35
+ if let m = command.transform { context.transform = CGAffineTransform(a: m[0], b: m[1], c: m[2], d: m[3], tx: m[4], ty: m[5]) }
36
+ context.opacity = command.opacity
37
+ if let mode = command.blendMode { setBlendMode(mode, of: &context) }
38
+ for filter in command.filters { context.addFilter(Self.filter(filter)) }
39
+ switch command.operation {
40
+ case .fill(let data, let shading, let evenOdd):
41
+ guard let path = Path(svg: data) else { return }
42
+ context.fill(path, with: Self.shading(shading), style: FillStyle(eoFill: evenOdd))
43
+ case .stroke(let data, let shading, let spec):
44
+ guard let path = Path(svg: data) else { return }
45
+ context.stroke(path, with: Self.shading(shading), style: StrokeStyle(
46
+ lineWidth: spec.lineWidth,
47
+ lineCap: spec.lineCap == "round" ? .round : spec.lineCap == "square" ? .square : .butt,
48
+ lineJoin: spec.lineJoin == "round" ? .round : spec.lineJoin == "bevel" ? .bevel : .miter,
49
+ miterLimit: spec.miterLimit ?? 10,
50
+ dash: spec.dash.map { CGFloat($0) },
51
+ dashPhase: spec.dashPhase
52
+ ))
53
+ case .text(let text, let x, let y, let anchor, let shading, let font):
54
+ var resolved = context.resolve(font.map { Text(verbatim: text).font(Font(spec: $0)) } ?? Text(verbatim: text))
55
+ resolved.shading = Self.shading(shading)
56
+ context.draw(resolved, at: CGPoint(x: x, y: y), anchor: UnitPoint(spec: anchor))
57
+ case .image(let image, let x, let y, let anchor, let width, let height, let shading):
58
+ // A symbol or a catalog image; a remote one has no bytes to draw synchronously here.
59
+ let source: Image
60
+ switch image {
61
+ case .symbol(let name, _): source = Image(systemName: name)
62
+ case .asset(let name, _): source = Image(name)
63
+ default: return
64
+ }
65
+ var resolved = context.resolve(source)
66
+ if let shading { resolved.shading = Self.shading(shading) }
67
+ if let width, let height {
68
+ let origin = CGPoint(x: x - anchor.x * width, y: y - anchor.y * height)
69
+ context.draw(resolved, in: CGRect(origin: origin, size: CGSize(width: width, height: height)))
70
+ } else {
71
+ context.draw(resolved, at: CGPoint(x: x, y: y), anchor: UnitPoint(spec: anchor))
72
+ }
73
+ }
74
+ }
75
+
76
+ static func shading(_ spec: CanvasShadingSpec) -> GraphicsContext.Shading {
77
+ let gradient = { (stops: [GradientStopSpec]) in Gradient(stops: stops.map { Gradient.Stop(color: Color(spec: $0.color), location: $0.location) }) }
78
+ switch spec {
79
+ case .style(let style):
80
+ return .style(AnyShapeStyle(spec: style))
81
+ case .linearGradient(let stops, let start, let end):
82
+ return .linearGradient(gradient(stops), startPoint: CGPoint(x: start.x, y: start.y), endPoint: CGPoint(x: end.x, y: end.y))
83
+ case .radialGradient(let stops, let center, let startRadius, let endRadius):
84
+ return .radialGradient(gradient(stops), center: CGPoint(x: center.x, y: center.y), startRadius: startRadius, endRadius: endRadius)
85
+ }
86
+ }
87
+
88
+ static func filter(_ spec: CanvasFilterSpec) -> GraphicsContext.Filter {
89
+ switch spec {
90
+ case .blur(let radius): .blur(radius: radius)
91
+ case .shadow(let color, let radius, let x, let y): .shadow(color: color.map(Color.init(spec:)) ?? Color.black.opacity(0.33), radius: radius, x: x, y: y)
92
+ case .grayscale(let amount): .grayscale(amount)
93
+ case .saturation(let amount): .saturation(amount)
94
+ case .brightness(let amount): .brightness(amount)
95
+ case .contrast(let amount): .contrast(amount)
96
+ case .hueRotation(let degrees): .hueRotation(.degrees(degrees))
97
+ case .invert(let amount): .colorInvert(amount)
98
+ }
99
+ }
100
+
101
+ /// The command's CSS blend mode. Assigned member by member: the context's blend mode is its own type
102
+ /// natively and the view's `BlendMode` in the shim, and both name these.
103
+ private func setBlendMode(_ name: String, of context: inout GraphicsContext) {
104
+ switch name {
105
+ case "multiply": context.blendMode = .multiply
106
+ case "screen": context.blendMode = .screen
107
+ case "overlay": context.blendMode = .overlay
108
+ case "darken": context.blendMode = .darken
109
+ case "lighten": context.blendMode = .lighten
110
+ case "color-dodge", "colorDodge": context.blendMode = .colorDodge
111
+ case "color-burn", "colorBurn": context.blendMode = .colorBurn
112
+ case "soft-light", "softLight": context.blendMode = .softLight
113
+ case "hard-light", "hardLight": context.blendMode = .hardLight
114
+ case "difference": context.blendMode = .difference
115
+ case "exclusion": context.blendMode = .exclusion
116
+ case "hue": context.blendMode = .hue
117
+ case "saturation": context.blendMode = .saturation
118
+ case "color": context.blendMode = .color
119
+ case "luminosity": context.blendMode = .luminosity
120
+ case "plus-lighter", "plusLighter": context.blendMode = .plusLighter
121
+ case "plus-darker", "plusDarker": context.blendMode = .plusDarker
122
+ default: context.blendMode = .normal
123
+ }
124
+ }
125
+ }
126
+
127
+ // MARK: - layout
128
+
129
+ /// `layout`: a SwiftUI `Layout` the app's JavaScript answers. After a pass it reports the proposal it
130
+ /// was placed with and each child's min, ideal and max sizes (`layout`, deferred past the pass, as
131
+ /// `geometry` is, and only when they changed); the app answers with the element's `size` and `frames`.
132
+ /// Until the first answer it fills its proposal; for another proposal it reads its flexibility from the
133
+ /// answer's `sizes`, and places the children at the last frames.
134
+ struct LayoutElement: View {
135
+ let node: ElementNode
136
+ @Environment(\.nativeApp) private var app
137
+ @State private var reporter = LayoutReporter()
138
+
139
+ var body: some View {
140
+ let reporter = reporter
141
+ let id = node.id
142
+ let app = app
143
+ let layout = AppLayout(props: LayoutProps(props: node.props)) { record in
144
+ reporter.report(RendererEvent.layout(id, proposal: record.proposal, children: record.children), app: app)
145
+ }
146
+ layout { ChildrenView(node: node) }
147
+ }
148
+ }
149
+
150
+ /// The last record a `layout` element sent: a pass that measured the same sends nothing.
151
+ final class LayoutReporter: @unchecked Sendable {
152
+ private var last: RendererEvent?
153
+
154
+ func report(_ event: RendererEvent, app: NativeApp?) {
155
+ guard event != last else { return }
156
+ last = event
157
+ Task { @MainActor in app?.dispatch(event) }
158
+ }
159
+ }
160
+
161
+ struct AppLayout: Layout {
162
+ struct Record {
163
+ var proposal: LayoutProposalSpec
164
+ var children: [LayoutMeasurementSpec]
165
+ }
166
+
167
+ let props: LayoutProps
168
+ let report: (Record) -> Void
169
+
170
+ private static func spec(_ proposal: ProposedViewSize) -> LayoutProposalSpec {
171
+ LayoutProposalSpec(width: proposal.width.map { LayoutDimensionSpec(Double($0)) }, height: proposal.height.map { LayoutDimensionSpec(Double($0)) })
172
+ }
173
+
174
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
175
+ if let size = props.size, props.proposal == Self.spec(proposal) { return CGSize(width: size.width, height: size.height) }
176
+ if let min = props.min, let ideal = props.ideal, let max = props.max {
177
+ func axis(_ proposed: CGFloat?, _ min: LayoutDimensionSpec, _ ideal: LayoutDimensionSpec, _ max: LayoutDimensionSpec) -> CGFloat {
178
+ guard let proposed else { return ideal.value.isFinite ? ideal.value : 0 }
179
+ let clamped = Swift.min(Swift.max(Double(proposed), min.value), max.value)
180
+ return clamped.isFinite ? clamped : (ideal.value.isFinite ? ideal.value : 0)
181
+ }
182
+ return CGSize(width: axis(proposal.width, min.width, ideal.width, max.width), height: axis(proposal.height, min.height, ideal.height, max.height))
183
+ }
184
+ return proposal.replacingUnspecifiedDimensions()
185
+ }
186
+
187
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
188
+ let measure = { (subview: LayoutSubview, proposal: ProposedViewSize) -> LayoutSizeSpec in
189
+ let size = subview.sizeThatFits(proposal)
190
+ return LayoutSizeSpec(width: Double(size.width), height: Double(size.height))
191
+ }
192
+ report(Record(
193
+ proposal: Self.spec(proposal),
194
+ children: subviews.map { LayoutMeasurementSpec(min: measure($0, .zero), ideal: measure($0, .unspecified), max: measure($0, .infinity)) }
195
+ ))
196
+ if let frames = props.frames, frames.count == subviews.count {
197
+ for (subview, frame) in zip(subviews, frames) {
198
+ subview.place(at: CGPoint(x: bounds.minX + frame.x, y: bounds.minY + frame.y), anchor: .topLeading, proposal: ProposedViewSize(width: frame.width, height: frame.height))
199
+ }
200
+ } else {
201
+ for subview in subviews { subview.place(at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(bounds.size)) }
202
+ }
203
+ }
204
+ }
205
+
206
+ // MARK: - drag and drop
207
+
208
+ /// `draggable`: SwiftUI's `.draggable`, carrying the element's id as text: what is dragged stays in
209
+ /// the app, which makes it at `dragstart` (sent as the drag begins) and hands it to the destination the
210
+ /// drop names. A drop on no destination sends no `dragend`; the app's next lift replaces what it kept.
211
+ struct DraggableElement: View {
212
+ let node: ElementNode
213
+ @Environment(\.nativeApp) private var app
214
+
215
+ var body: some View {
216
+ let id = node.id
217
+ let app = app
218
+ content.draggable(Self.lift(id, app))
219
+ }
220
+
221
+ @ViewBuilder private var content: some View {
222
+ if node.children.count == 1 { ElementView(node: node.children[0]) } else { ChildrenView(node: node) }
223
+ }
224
+
225
+ /// The payload, made as the drag begins: the app hears `dragstart` first.
226
+ private static func lift(_ id: Int, _ app: NativeApp?) -> String {
227
+ Task { @MainActor in app?.dispatch(.dragStart(id)) }
228
+ return DragPayload.text(for: id)
229
+ }
230
+ }
231
+
232
+ #if !canImport(FoundationBridge)
233
+ /// What a drop from another app carries: text (or a link) as the page offers it, or a file or folder, copied
234
+ /// into a tmp folder of the app's while the drop runs (a received file is the system's only until then).
235
+ struct DropPayload: Transferable {
236
+ enum Kind {
237
+ case text(String)
238
+ case file(URL, folder: Bool)
239
+ }
240
+ let kind: Kind
241
+
242
+ static var transferRepresentation: some TransferRepresentation {
243
+ FileRepresentation(importedContentType: .folder) { received in
244
+ DropPayload(kind: .file(try Self.keep(received.file), folder: true))
245
+ }
246
+ FileRepresentation(importedContentType: .item) { received in
247
+ DropPayload(kind: .file(try Self.keep(received.file), folder: false))
248
+ }
249
+ ProxyRepresentation { (url: URL) in DropPayload(kind: .text(url.absoluteString)) }
250
+ ProxyRepresentation { (text: String) in DropPayload(kind: .text(text)) }
251
+ }
252
+
253
+ /// The received item copied into a folder of its own in tmp, registered as picked (PickedFiles) until the app releases it.
254
+ private static func keep(_ received: URL) throws -> URL {
255
+ let folder = FileManager.default.temporaryDirectory.appendingPathComponent("swiftbrowser-drops-\(UUID().uuidString)", isDirectory: true)
256
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
257
+ let kept = folder.appendingPathComponent(received.lastPathComponent)
258
+ try FileManager.default.copyItem(at: received, to: kept)
259
+ PickedFiles.shared.register([kept], temporary: folder)
260
+ return kept
261
+ }
262
+ }
263
+
264
+ /// `dropdestination`: SwiftUI's `.dropDestination`. A drag over it sends `drop` `entered` and `exited`; a
265
+ /// drop sends `performed` with the `draggable` it came from (and that one's `dragend`), or, for what another
266
+ /// app dragged in and `accepts` takes, the text as `items`, or each file (a dropped folder's at any depth, named
267
+ /// by their paths in it) as an item still on disk: `{ type, name, path?, url, size }`, which the app reads on
268
+ /// demand through the files bridge (a `LazyFile` in @swiftbrowser/native).
269
+ struct DropDestinationElement: View {
270
+ let node: ElementNode
271
+ @Environment(\.nativeApp) private var app
272
+
273
+ var body: some View {
274
+ let id = node.id
275
+ let app = app
276
+ let props = DropDestinationProps(props: node.props)
277
+ content.dropDestination(for: DropPayload.self) { payloads, location in
278
+ var events: [RendererEvent] = []
279
+ var outside: [JSONValue] = []
280
+ for payload in payloads {
281
+ switch payload.kind {
282
+ case .text(let text):
283
+ if let source = DragPayload.source(of: text) {
284
+ events.append(.drop(id, phase: "performed", source: source, x: location.x, y: location.y))
285
+ events.append(.dragEnd(source))
286
+ } else if props.acceptsText {
287
+ let link = URL(string: text).map { $0.scheme != nil && !text.contains(where: \.isWhitespace) } ?? false
288
+ outside.append(.object(["type": .string(link ? "text/uri-list" : "text/plain"), "text": .string(text)]))
289
+ }
290
+ case .file(let url, let folder):
291
+ guard props.acceptsFiles else { continue }
292
+ outside.append(contentsOf: Self.fileItems(url, folder: folder, props: props))
293
+ }
294
+ }
295
+ if !outside.isEmpty { events.append(.drop(id, phase: "performed", items: outside, x: location.x, y: location.y)) }
296
+ guard !events.isEmpty else { return false }
297
+ let sent = events
298
+ Task { @MainActor in for event in sent { app?.dispatch(event) } }
299
+ return true
300
+ } isTargeted: { targeted in
301
+ Task { @MainActor in app?.dispatch(.drop(id, phase: targeted ? "entered" : "exited")) }
302
+ }
303
+ }
304
+
305
+ /// A dropped file as one item, or a dropped folder's files (at any depth, hidden ones left out) as items named by
306
+ /// their paths from the folder, the folder's name first, as the page names a dropped folder's files.
307
+ private static func fileItems(_ url: URL, folder: Bool, props: DropDestinationProps) -> [JSONValue] {
308
+ let mime = { (file: URL) in UTType(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream" }
309
+ let item = { (file: URL, path: String?) -> JSONValue in
310
+ let size = (try? file.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
311
+ var value: JSONObject = [
312
+ "type": .string(mime(file)),
313
+ "name": .string(file.lastPathComponent),
314
+ "url": .string(file.standardizedFileURL.absoluteString),
315
+ "size": .number(Double(size)),
316
+ ]
317
+ if let path { value["path"] = .string(path) }
318
+ return .object(value)
319
+ }
320
+ guard folder else { return props.accepts(mimeType: mime(url)) ? [item(url, nil)] : [] }
321
+ let root = url.standardizedFileURL.pathComponents
322
+ guard let walker = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return [] }
323
+ var items: [JSONValue] = []
324
+ var urls: [URL] = []
325
+ for case let file as URL in walker {
326
+ guard (try? file.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true, props.accepts(mimeType: mime(file)) else { continue }
327
+ let components = file.standardizedFileURL.pathComponents
328
+ guard components.count > root.count else { continue }
329
+ items.append(item(file, ([url.lastPathComponent] + components.dropFirst(root.count)).joined(separator: "/")))
330
+ urls.append(file)
331
+ }
332
+ PickedFiles.shared.register(urls)
333
+ return items
334
+ }
335
+
336
+ @ViewBuilder private var content: some View {
337
+ if node.children.count == 1 { ElementView(node: node.children[0]) } else { ChildrenView(node: node) }
338
+ }
339
+ }
340
+ #else
341
+ /// `dropdestination`: SwiftUI's `.dropDestination(for: String.self)`, against the shim (the round trip): in-app
342
+ /// drags, and text from outside that `accepts` takes; files need the system's `Transferable` (above).
343
+ struct DropDestinationElement: View {
344
+ let node: ElementNode
345
+ @Environment(\.nativeApp) private var app
346
+
347
+ var body: some View {
348
+ let id = node.id
349
+ let app = app
350
+ let accepts = DropDestinationProps(props: node.props).acceptsText
351
+ content.dropDestination(for: String.self) { texts, location in
352
+ var events: [RendererEvent] = []
353
+ var outside: [JSONValue] = []
354
+ for text in texts {
355
+ if let source = DragPayload.source(of: text) {
356
+ events.append(.drop(id, phase: "performed", source: source, x: location.x, y: location.y))
357
+ events.append(.dragEnd(source))
358
+ } else if accepts {
359
+ let link = URL(string: text).map { $0.scheme != nil && !text.contains(where: \.isWhitespace) } ?? false
360
+ outside.append(.object(["type": .string(link ? "text/uri-list" : "text/plain"), "text": .string(text)]))
361
+ }
362
+ }
363
+ if !outside.isEmpty { events.append(.drop(id, phase: "performed", items: outside, x: location.x, y: location.y)) }
364
+ guard !events.isEmpty else { return false }
365
+ let sent = events
366
+ Task { @MainActor in for event in sent { app?.dispatch(event) } }
367
+ return true
368
+ } isTargeted: { targeted in
369
+ Task { @MainActor in app?.dispatch(.drop(id, phase: targeted ? "entered" : "exited")) }
370
+ }
371
+ }
372
+
373
+ @ViewBuilder private var content: some View {
374
+ if node.children.count == 1 { ElementView(node: node.children[0]) } else { ChildrenView(node: node) }
375
+ }
376
+ }
377
+ #endif
378
+ #endif
@@ -8,9 +8,9 @@ import SwiftUI
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
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.
11
+ /// (PopTap.swift: once UIKit's pop transition has finished, and not for a
12
+ /// cancelled swipe), and the app removes the child, so the next read of the
13
+ /// path agrees with the screen.
14
14
  struct NavigationStackElement: View {
15
15
  let node: ElementNode
16
16
  @Environment(\.nativeApp) private var app
@@ -28,10 +28,12 @@ struct NavigationStackElement: View {
28
28
  let screens = node?.children.filter { $0.kind == "navscreen" } ?? []
29
29
  let pushed = screens.dropFirst().map(\.id)
30
30
  let path = Binding<[Int]>(
31
- get: { node?.children.filter { $0.kind == "navscreen" }.dropFirst().map(\.id) ?? [] },
31
+ // A screen whose pop is under way is left out, as SwiftUI's own
32
+ // path has it, while the tree keeps it until the pop has finished.
33
+ get: { node?.children.filter { $0.kind == "navscreen" }.dropFirst().map(\.id).filter { !PopTap.pending.contains($0) } ?? [] },
32
34
  set: { next in
33
35
  // Only pops come from here (pushes come from the app; a
34
- // cancelled swipe puts its id back, which the getter kept).
36
+ // cancelled swipe puts its id back).
35
37
  for id in pushed where !next.contains(id) {
36
38
  PopTap.send(id, app: app)
37
39
  }
@@ -318,12 +320,15 @@ struct NavigationLinkElement: View {
318
320
 
319
321
  /// Whether the list around is in edit mode with a selection: a link row
320
322
  /// 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
+ /// followed, as iOS selects a `NavigationLink` row then. A split view
324
+ /// sidebar's link (`selects`) is its row's selection always: the cell
325
+ /// takes the touch and selects itself before the app pushes, so the
326
+ /// list does not change under the push's transition.
327
+ private func selecting(_ props: NavLinkProps) -> Bool {
323
328
  #if os(iOS) || canImport(FoundationBridge)
324
- listSelects && editMode?.wrappedValue.isEditing == true
329
+ listSelects && (props.selects || editMode?.wrappedValue.isEditing == true)
325
330
  #else
326
- false
331
+ listSelects && props.selects
327
332
  #endif
328
333
  }
329
334
 
@@ -349,7 +354,7 @@ struct NavigationLinkElement: View {
349
354
  ChildrenView(node: node)
350
355
  }
351
356
  }
352
- let tappable: AnyView = inList && selecting
357
+ let tappable: AnyView = inList && selecting(props)
353
358
  ? AnyView(label)
354
359
  : AnyView(Button { app?.dispatch(.tap(node.id)) } label: { label }.buttonStyle(.plain))
355
360
  tappable
@@ -6,21 +6,24 @@ import UIKit
6
6
  #endif
7
7
 
8
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.
9
+ /// timed to the pop. SwiftUI sets the stack's path as UIKit's pop begins (a
10
+ /// back button's tap, or the start of an edge swipe, before anyone knows
11
+ /// whether it will go through); the tap waits until the transition has
12
+ /// finished, and a cancelled swipe sends none. Meanwhile the screen is
13
+ /// `pending`: the stack's path leaves it out, as SwiftUI's own has, while
14
+ /// the tree still has it, so the departing screen is drawn as it is, title
15
+ /// included. Removed at once, it was drawn from the tree's record of it
16
+ /// (`OpTree.poppedScreen`) and UIKit dropped its large title in the pop's
17
+ /// first frame. Without a transition (a programmatic pop, or no navigation
18
+ /// controller to ask) the tap goes at once.
19
19
  enum PopTap {
20
+ /// The screens whose pop is under way: their taps wait for its end.
21
+ @MainActor private(set) static var pending: Set<Int> = []
22
+
20
23
  @MainActor
21
24
  static func send(_ id: Int, app: NativeApp?) {
22
25
  #if os(iOS) && !canImport(FoundationBridge)
23
- if let coordinator = interactivePop() {
26
+ if let coordinator = runningTransition() {
24
27
  send(id, during: CoordinatorTransition(coordinator: coordinator)) { app?.dispatch(.tap($0)) }
25
28
  return
26
29
  }
@@ -28,27 +31,30 @@ enum PopTap {
28
31
  app?.dispatch(.tap(id))
29
32
  }
30
33
 
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.
34
+ /// The tap through the pop's transition: once it has finished, and only
35
+ /// when the pop went through; at once without one.
33
36
  @MainActor
34
- static func send(_ id: Int, during transition: any PopTransition, dispatch: @escaping @MainActor (Int) -> Void) {
35
- guard transition.isInteractive else {
37
+ static func send(_ id: Int, during transition: (any PopTransition)?, dispatch: @escaping @MainActor (Int) -> Void) {
38
+ guard let transition else {
36
39
  dispatch(id)
37
40
  return
38
41
  }
39
- transition.whenInteractionEnds { cancelled in
42
+ guard !pending.contains(id) else { return }
43
+ pending.insert(id)
44
+ transition.whenFinished { cancelled in
45
+ pending.remove(id)
40
46
  if !cancelled { dispatch(id) }
41
47
  }
42
48
  }
43
49
 
44
50
  #if os(iOS) && !canImport(FoundationBridge)
45
- /// The transition of a navigation controller in the app's windows that a finger is driving, if any.
51
+ /// The transition of a navigation controller in the app's windows, if one is running.
46
52
  @MainActor
47
- private static func interactivePop() -> (any UIViewControllerTransitionCoordinator)? {
53
+ private static func runningTransition() -> (any UIViewControllerTransitionCoordinator)? {
48
54
  let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows)
49
55
  for window in windows {
50
56
  for nav in navigationControllers(in: window) {
51
- if let coordinator = nav.transitionCoordinator, coordinator.isInteractive { return coordinator }
57
+ if let coordinator = nav.transitionCoordinator { return coordinator }
52
58
  }
53
59
  }
54
60
  return nil
@@ -72,13 +78,12 @@ enum PopTap {
72
78
  #endif
73
79
  }
74
80
 
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).
81
+ /// A pop's transition as `PopTap` reads it: word of its end, and whether
82
+ /// the pop went through (UIKit's transition coordinator, or a test's).
77
83
  @MainActor
78
84
  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)
85
+ /// Calls `handler` once the transition has finished, with whether the pop was cancelled.
86
+ func whenFinished(_ handler: @escaping @MainActor (_ cancelled: Bool) -> Void)
82
87
  }
83
88
 
84
89
  #if os(iOS) && !canImport(FoundationBridge)
@@ -87,10 +92,8 @@ protocol PopTransition {
87
92
  struct CoordinatorTransition: PopTransition {
88
93
  let coordinator: any UIViewControllerTransitionCoordinator
89
94
 
90
- var isInteractive: Bool { coordinator.isInteractive }
91
-
92
- func whenInteractionEnds(_ handler: @escaping @MainActor (_ cancelled: Bool) -> Void) {
93
- coordinator.notifyWhenInteractionChanges { context in
95
+ func whenFinished(_ handler: @escaping @MainActor (_ cancelled: Bool) -> Void) {
96
+ coordinator.animate(alongsideTransition: nil) { context in
94
97
  let cancelled = context.isCancelled
95
98
  MainActor.assumeIsolated { handler(cancelled) }
96
99
  }