@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.
- package/README.md +34 -11
- package/dist/blob.d.ts +85 -11
- package/dist/blob.d.ts.map +1 -1
- package/dist/blob.js +184 -34
- package/dist/blob.js.map +1 -1
- package/dist/bridge.d.ts +33 -2
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/fetch.d.ts +19 -11
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +105 -41
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +3 -1
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +47 -6
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime.js +1 -1
- package/dist/runtime.js.map +1 -1
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +13 -1
- package/dist/webSocket.js.map +1 -1
- package/package.json +3 -3
- package/src/blob.ts +215 -34
- package/src/bridge.ts +27 -2
- package/src/fetch.ts +99 -41
- package/src/hostRequests.ts +62 -8
- package/src/index.ts +1 -2
- package/src/runtime.ts +1 -1
- package/src/webSocket.ts +10 -1
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +185 -24
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +2075 -213
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +407 -16
- package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserShader/README.md +45 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
- package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +141 -7
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +47 -1
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +98 -29
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
- package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +11 -7
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +28 -6
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +15 -10
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +31 -28
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +142 -0
- package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +5 -2
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
- package/swift/Sources/SwiftBrowserProtocol/TextChange.swift +17 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
- package/dist/native.d.ts +0 -19
- package/dist/native.d.ts.map +0 -1
- package/dist/native.js +0 -17
- package/dist/native.js.map +0 -1
- package/src/native.ts +0 -35
- 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: {
|
|
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
|
-
///
|
|
263
|
-
private func
|
|
264
|
-
|
|
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:
|
|
12
|
-
/// swipe), and the app removes the child, so the next read of the
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
10
|
-
///
|
|
11
|
-
///
|
|
12
|
-
///
|
|
13
|
-
///
|
|
14
|
-
///
|
|
15
|
-
///
|
|
16
|
-
///
|
|
17
|
-
///
|
|
18
|
-
///
|
|
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 =
|
|
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:
|
|
32
|
-
///
|
|
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
|
|
35
|
-
guard transition
|
|
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
|
-
|
|
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
|
|
51
|
+
/// The transition of a navigation controller in the app's windows, if one is running.
|
|
46
52
|
@MainActor
|
|
47
|
-
private static func
|
|
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
|
|
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:
|
|
76
|
-
///
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
}
|