@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.
- package/README.md +19 -0
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/bridge.d.ts +34 -7
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/encoding.d.ts +13 -0
- package/dist/encoding.d.ts.map +1 -1
- package/dist/encoding.js +24 -0
- package/dist/encoding.js.map +1 -1
- package/dist/fetch.d.ts +10 -4
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +19 -11
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +22 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +65 -13
- package/dist/hostRequests.js.map +1 -1
- package/dist/ios.d.ts +79 -0
- package/dist/ios.d.ts.map +1 -0
- package/dist/ios.js +181 -0
- package/dist/ios.js.map +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +16 -7
- package/dist/runtime.js.map +1 -1
- package/dist/subtle.d.ts +1 -1
- package/dist/subtle.d.ts.map +1 -1
- package/dist/subtle.js +9 -5
- package/dist/subtle.js.map +1 -1
- package/dist/webSocket.d.ts +12 -5
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +24 -10
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/bridge.ts +30 -7
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +18 -13
- package/src/hostRequests.ts +72 -19
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +27 -12
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -0
- package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
- package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +81 -20
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#if canImport(SwiftUI) && !canImport(FoundationBridge)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
|
|
5
|
+
/// A language model an app brings for `LanguageModelSession`
|
|
6
|
+
/// (LanguageModelBridge.swift) where Apple Intelligence cannot answer: set
|
|
7
|
+
/// `LanguageModels.fallback` at launch, and the app's sessions use it
|
|
8
|
+
/// whenever the system model is unavailable for a reason other than still
|
|
9
|
+
/// downloading. SwiftBrowserLocalModel (Native/Packages) is one, running an
|
|
10
|
+
/// open model with llama.cpp; an app that adds no such package pays nothing
|
|
11
|
+
/// for this hook.
|
|
12
|
+
@MainActor
|
|
13
|
+
public protocol LanguageModelProvider: AnyObject {
|
|
14
|
+
/// Whether it can answer now, asked each time the app asks. A provider
|
|
15
|
+
/// whose model is not on the device yet answers `.unavailable(.modelNotReady)`
|
|
16
|
+
/// and says how its download stands in `downloadState`.
|
|
17
|
+
func availability() async -> LanguageModelAvailability
|
|
18
|
+
|
|
19
|
+
/// For a model the app fetches: its size, whether it is on the device,
|
|
20
|
+
/// and how far a download under way has come. Nil (the default) for a
|
|
21
|
+
/// model that is simply there.
|
|
22
|
+
var downloadState: LanguageModelDownload? { get }
|
|
23
|
+
|
|
24
|
+
/// Starts fetching the model, which the viewer asked for (the app shows
|
|
25
|
+
/// the size first). Nothing for a model without a download.
|
|
26
|
+
func startDownload()
|
|
27
|
+
|
|
28
|
+
/// Stops a download under way and removes the model from the device,
|
|
29
|
+
/// freeing its space; it can be fetched again.
|
|
30
|
+
func deleteDownload() async throws
|
|
31
|
+
|
|
32
|
+
/// A conversation with `instructions` followed throughout, and `tools`
|
|
33
|
+
/// the model may call while it answers. Making one should be cheap: load
|
|
34
|
+
/// what it needs on `prewarm` or the first prompt.
|
|
35
|
+
func makeSession(instructions: String?, tools: [LanguageModelTool]) throws -> any LanguageModelProviderSession
|
|
36
|
+
|
|
37
|
+
/// The models it can run, for the app to offer: empty (the default) for
|
|
38
|
+
/// a provider of one model.
|
|
39
|
+
var choices: [LanguageModelChoice] { get }
|
|
40
|
+
|
|
41
|
+
/// The choice that answers, by its `id`.
|
|
42
|
+
var selectedChoice: String? { get }
|
|
43
|
+
|
|
44
|
+
/// Makes the choice with this `id` the one that answers, from the next session on.
|
|
45
|
+
func select(_ id: String)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// One conversation: each prompt follows the ones before it and their responses.
|
|
49
|
+
@MainActor
|
|
50
|
+
public protocol LanguageModelProviderSession: AnyObject {
|
|
51
|
+
/// Loads what the first response needs, ahead of it.
|
|
52
|
+
func prewarm()
|
|
53
|
+
|
|
54
|
+
/// The response to `prompt`, each element the whole response so far.
|
|
55
|
+
/// Ending the iteration (or cancelling the task iterating it) stops the
|
|
56
|
+
/// model. Failures are best thrown as `LanguageModelError`s, whose kinds
|
|
57
|
+
/// the app reads as `GenerationError` cases.
|
|
58
|
+
func streamResponse(to prompt: String, options: LanguageModelOptions) -> AsyncThrowingStream<String, Error>
|
|
59
|
+
|
|
60
|
+
/// The app is done with the conversation.
|
|
61
|
+
func close()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@MainActor
|
|
65
|
+
extension LanguageModelProvider {
|
|
66
|
+
/// A conversation without tools.
|
|
67
|
+
public func makeSession(instructions: String?) throws -> any LanguageModelProviderSession {
|
|
68
|
+
try makeSession(instructions: instructions, tools: [])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public var choices: [LanguageModelChoice] { [] }
|
|
72
|
+
public var selectedChoice: String? { nil }
|
|
73
|
+
public func select(_ id: String) {}
|
|
74
|
+
public var downloadState: LanguageModelDownload? { nil }
|
|
75
|
+
public func startDownload() {}
|
|
76
|
+
public func deleteDownload() async throws {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// A function the model may call while it answers (FoundationModels' `Tool`):
|
|
80
|
+
/// its name and what it does, its arguments as a JSON schema (an object of
|
|
81
|
+
/// properties; strings, numbers, integers, booleans, arrays and enums), and
|
|
82
|
+
/// `call`, which runs it (in the app) and answers what the model reads.
|
|
83
|
+
public struct LanguageModelTool: Sendable {
|
|
84
|
+
public let name: String
|
|
85
|
+
public let description: String
|
|
86
|
+
public let parameters: JSONValue
|
|
87
|
+
public let call: @Sendable (JSONValue) async throws -> String
|
|
88
|
+
|
|
89
|
+
public init(name: String, description: String, parameters: JSONValue, call: @escaping @Sendable (JSONValue) async throws -> String) {
|
|
90
|
+
self.name = name
|
|
91
|
+
self.description = description
|
|
92
|
+
self.parameters = parameters
|
|
93
|
+
self.call = call
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// A model the app can offer to choose (`LanguageModelProvider.choices`).
|
|
98
|
+
public struct LanguageModelChoice: Sendable, Equatable {
|
|
99
|
+
public var id: String
|
|
100
|
+
public var name: String
|
|
101
|
+
/// A line about it: what it is good at.
|
|
102
|
+
public var summary: String?
|
|
103
|
+
/// Where its download stands, for a model the app fetches.
|
|
104
|
+
public var download: LanguageModelDownload?
|
|
105
|
+
|
|
106
|
+
public init(id: String, name: String, summary: String? = nil, download: LanguageModelDownload? = nil) {
|
|
107
|
+
self.id = id
|
|
108
|
+
self.name = name
|
|
109
|
+
self.summary = summary
|
|
110
|
+
self.download = download
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Where a model the app fetches stands.
|
|
115
|
+
public struct LanguageModelDownload: Sendable, Equatable {
|
|
116
|
+
/// The file's size in bytes, where known.
|
|
117
|
+
public var size: Int64?
|
|
118
|
+
/// Whether the model is on the device.
|
|
119
|
+
public var isDownloaded: Bool
|
|
120
|
+
/// From 0 to 1 while a download is under way; nil when none is.
|
|
121
|
+
public var progress: Double?
|
|
122
|
+
/// Why the last download failed, until the next one starts.
|
|
123
|
+
public var failure: String?
|
|
124
|
+
|
|
125
|
+
public init(size: Int64? = nil, isDownloaded: Bool, progress: Double? = nil, failure: String? = nil) {
|
|
126
|
+
self.size = size
|
|
127
|
+
self.isDownloaded = isDownloaded
|
|
128
|
+
self.progress = progress
|
|
129
|
+
self.failure = failure
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// Whether a model can answer, as `SystemLanguageModel.Availability`.
|
|
134
|
+
public enum LanguageModelAvailability: Sendable, Equatable {
|
|
135
|
+
case available
|
|
136
|
+
case unavailable(Reason)
|
|
137
|
+
|
|
138
|
+
public enum Reason: String, Sendable {
|
|
139
|
+
case deviceNotEligible
|
|
140
|
+
case appleIntelligenceNotEnabled
|
|
141
|
+
case modelNotReady
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// `GenerationOptions`, as the app sends them.
|
|
146
|
+
public struct LanguageModelOptions: Sendable, Equatable {
|
|
147
|
+
public var temperature: Double?
|
|
148
|
+
public var maximumResponseTokens: Int?
|
|
149
|
+
/// Always the likeliest token, rather than sampling.
|
|
150
|
+
public var greedy: Bool
|
|
151
|
+
|
|
152
|
+
public init(temperature: Double? = nil, maximumResponseTokens: Int? = nil, greedy: Bool = false) {
|
|
153
|
+
self.temperature = temperature
|
|
154
|
+
self.maximumResponseTokens = maximumResponseTokens
|
|
155
|
+
self.greedy = greedy
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// A failed response, by the `GenerationError` case the app reads it as.
|
|
160
|
+
public struct LanguageModelError: Error, CustomStringConvertible, Sendable {
|
|
161
|
+
public enum Kind: String, Sendable {
|
|
162
|
+
/// `assetsUnavailable`
|
|
163
|
+
case unavailable
|
|
164
|
+
/// `exceededContextWindowSize`
|
|
165
|
+
case context
|
|
166
|
+
/// `guardrailViolation`
|
|
167
|
+
case guardrail
|
|
168
|
+
/// `unsupportedLanguageOrLocale`
|
|
169
|
+
case language
|
|
170
|
+
/// `concurrentRequests`
|
|
171
|
+
case concurrent
|
|
172
|
+
case rateLimited
|
|
173
|
+
case refusal
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public let kind: Kind
|
|
177
|
+
public let message: String
|
|
178
|
+
|
|
179
|
+
public init(_ kind: Kind, _ message: String) {
|
|
180
|
+
self.kind = kind
|
|
181
|
+
self.message = message
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// The tagged message the app reads the case from (`context: …`).
|
|
185
|
+
public var description: String { "\(kind.rawValue): \(message)" }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// The models `LanguageModelSession` can use besides Apple Intelligence's.
|
|
189
|
+
public enum LanguageModels {
|
|
190
|
+
/// Used where the system model is unavailable for a reason other than
|
|
191
|
+
/// still downloading (an ineligible device, Apple Intelligence off, a
|
|
192
|
+
/// system before iOS 26). Set it at launch, before the app asks.
|
|
193
|
+
@MainActor public static var fallback: (any LanguageModelProvider)?
|
|
194
|
+
}
|
|
195
|
+
#endif
|
|
@@ -10,22 +10,26 @@ import FoundationBridge
|
|
|
10
10
|
/// first ping is answered (the handshake is done by then), `wsSend` sends
|
|
11
11
|
/// text or bytes, `wsClose` closes it. What a socket receives, and how it
|
|
12
12
|
/// ends, goes to the app as `__sbWebSocketEvent(id, json)`: `open`,
|
|
13
|
-
/// `message` (`text` or
|
|
14
|
-
/// `
|
|
13
|
+
/// `message` (`text` or `binary`), `error` and `close` (`code`, `reason`,
|
|
14
|
+
/// `wasClean`). Binary crosses as base64, or, for a socket the app opened
|
|
15
|
+
/// saying it reads bytes, as a `Uint8Array` after the JSON, which names it
|
|
16
|
+
/// `{ "$bytes": 0 }` (docs/notes/bytes.md); `wsSend` takes it either way. Against the shim (the round trip) the task is the
|
|
15
17
|
/// shim's, over the page's WebSocket.
|
|
16
18
|
@MainActor
|
|
17
19
|
final class WebSocketBridge {
|
|
18
20
|
private let session: URLSession
|
|
19
|
-
private let send: (Int, JSONValue) -> Void
|
|
21
|
+
private let send: (Int, JSONValue, [HostBytes]) -> Void
|
|
20
22
|
private var tasks: [Int: URLSessionWebSocketTask] = [:]
|
|
23
|
+
/// The sockets whose app reads binary messages as bytes.
|
|
24
|
+
private var bytesSockets: Set<Int> = []
|
|
21
25
|
|
|
22
|
-
/// `send` hands an event for socket `id` to the app.
|
|
23
|
-
init(session: URLSession, send: @escaping (Int, JSONValue) -> Void) {
|
|
26
|
+
/// `send` hands an event for socket `id` to the app, with the bytes its JSON names.
|
|
27
|
+
init(session: URLSession, send: @escaping (Int, JSONValue, [HostBytes]) -> Void) {
|
|
24
28
|
self.session = session
|
|
25
29
|
self.send = send
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
func open(id: Int, url string: String, protocols: [String]) {
|
|
32
|
+
func open(id: Int, url string: String, protocols: [String], bytes: Bool = false) {
|
|
29
33
|
guard let url = URL(string: string) else {
|
|
30
34
|
emit(id, ["type": .string("error"), "message": .string("invalid URL")])
|
|
31
35
|
emit(id, ["type": .string("close"), "code": .number(1006), "reason": .string(""), "wasClean": .bool(false)])
|
|
@@ -33,6 +37,7 @@ final class WebSocketBridge {
|
|
|
33
37
|
}
|
|
34
38
|
let task = protocols.isEmpty ? session.webSocketTask(with: url) : session.webSocketTask(with: url, protocols: protocols)
|
|
35
39
|
tasks[id] = task
|
|
40
|
+
if bytes { bytesSockets.insert(id) }
|
|
36
41
|
task.resume()
|
|
37
42
|
task.sendPing { [weak self] error in
|
|
38
43
|
Task { @MainActor [weak self] in
|
|
@@ -47,13 +52,13 @@ final class WebSocketBridge {
|
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
/// `{ text }` or `{ binary }`
|
|
51
|
-
func send(id: Int, json: String) {
|
|
55
|
+
/// `{ text }` or `{ binary }`: base64, or `{ "$bytes": 0 }` and the view in `bytes`.
|
|
56
|
+
func send(id: Int, json: String, bytes: [HostBytes] = []) {
|
|
52
57
|
guard let task = tasks[id], let fields = (try? JSONValue.parse(json))?.object else { return }
|
|
53
58
|
let message: URLSessionWebSocketTask.Message
|
|
54
59
|
if let text = fields["text"]?.string {
|
|
55
60
|
message = .string(text)
|
|
56
|
-
} else if let
|
|
61
|
+
} else if let data = fields["binary"]?.bytes(from: bytes) {
|
|
57
62
|
message = .data(data)
|
|
58
63
|
} else {
|
|
59
64
|
return
|
|
@@ -69,6 +74,7 @@ final class WebSocketBridge {
|
|
|
69
74
|
|
|
70
75
|
func close(id: Int, code: Int, reason: String) {
|
|
71
76
|
guard let task = tasks.removeValue(forKey: id) else { return }
|
|
77
|
+
defer { bytesSockets.remove(id) }
|
|
72
78
|
task.cancel(with: URLSessionWebSocketTask.CloseCode(rawValue: code) ?? .normalClosure, reason: reason.isEmpty ? nil : Data(reason.utf8))
|
|
73
79
|
emit(id, ["type": .string("close"), "code": .number(Double(code)), "reason": .string(reason), "wasClean": .bool(true)])
|
|
74
80
|
}
|
|
@@ -77,6 +83,7 @@ final class WebSocketBridge {
|
|
|
77
83
|
func invalidate() {
|
|
78
84
|
for task in tasks.values { task.cancel(with: .goingAway, reason: nil) }
|
|
79
85
|
tasks.removeAll()
|
|
86
|
+
bytesSockets.removeAll()
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
private func receive(id: Int, task: URLSessionWebSocketTask) {
|
|
@@ -87,7 +94,12 @@ final class WebSocketBridge {
|
|
|
87
94
|
case .success(let message):
|
|
88
95
|
switch message {
|
|
89
96
|
case .string(let text): self.emit(id, ["type": .string("message"), "text": .string(text)])
|
|
90
|
-
case .data(let data):
|
|
97
|
+
case .data(let data):
|
|
98
|
+
if self.bytesSockets.contains(id) {
|
|
99
|
+
self.send(id, .object(["type": .string("message"), "binary": .object(["$bytes": .number(0)])]), [HostBytes(data, kind: .uint8)])
|
|
100
|
+
} else {
|
|
101
|
+
self.emit(id, ["type": .string("message"), "binary": .string(data.base64EncodedString())])
|
|
102
|
+
}
|
|
91
103
|
@unknown default: break
|
|
92
104
|
}
|
|
93
105
|
self.receive(id: id, task: task)
|
|
@@ -101,6 +113,7 @@ final class WebSocketBridge {
|
|
|
101
113
|
/// The socket ended: a close the server sent (its code and reason), else an error and an unclean close.
|
|
102
114
|
private func fail(id: Int, task: URLSessionWebSocketTask, error: any Error) {
|
|
103
115
|
tasks[id] = nil
|
|
116
|
+
bytesSockets.remove(id)
|
|
104
117
|
let code = task.closeCode
|
|
105
118
|
if code != .invalid {
|
|
106
119
|
let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? ""
|
|
@@ -112,7 +125,7 @@ final class WebSocketBridge {
|
|
|
112
125
|
}
|
|
113
126
|
|
|
114
127
|
private func emit(_ id: Int, _ fields: JSONObject) {
|
|
115
|
-
send(id, .object(fields))
|
|
128
|
+
send(id, .object(fields), [])
|
|
116
129
|
}
|
|
117
130
|
}
|
|
118
131
|
#endif
|
|
@@ -62,7 +62,8 @@ final class WorkerHost: @unchecked Sendable {
|
|
|
62
62
|
// MARK: On the worker's thread
|
|
63
63
|
|
|
64
64
|
private func setUp() {
|
|
65
|
-
|
|
65
|
+
// Its own virtual machine, made one at a time with the app's (JavaScriptCoreHost.swift).
|
|
66
|
+
let context = JavaScriptCoreHost.withVirtualMachineLock { JSContext(virtualMachine: JSVirtualMachine())! }
|
|
66
67
|
context.name = "SwiftBrowser worker \(name)"
|
|
67
68
|
context.exceptionHandler = { [weak self] _, exception in
|
|
68
69
|
guard let self else { return }
|
|
@@ -85,7 +86,7 @@ final class WorkerHost: @unchecked Sendable {
|
|
|
85
86
|
for timer in timers.values { timer.invalidate() }
|
|
86
87
|
timers.removeAll()
|
|
87
88
|
context?.exceptionHandler = nil
|
|
88
|
-
context = nil
|
|
89
|
+
JavaScriptCoreHost.withVirtualMachineLock { context = nil }
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
private func install(_ native: JSValue, _ name: String, _ body: @escaping ([JSONValue]) -> JSONValue?) {
|
|
@@ -61,6 +61,7 @@ struct ElementView: View {
|
|
|
61
61
|
// Lists
|
|
62
62
|
case "list": return AnyView(ListElement(node: node))
|
|
63
63
|
case "section": return AnyView(SectionElement(node: node))
|
|
64
|
+
case "disclosuregroup": return AnyView(DisclosureGroupElement(node: node))
|
|
64
65
|
case "listrow": return AnyView(ListRowElement(node: node))
|
|
65
66
|
case "swipeactions": return AnyView(SwipeActionsElement(node: node))
|
|
66
67
|
// Navigation and presentation
|
|
@@ -527,7 +527,24 @@ struct SliderElement: View {
|
|
|
527
527
|
let range = props.min...max(props.min, props.max)
|
|
528
528
|
// The drag's start and end, with the value then (`onEditingChanged`).
|
|
529
529
|
let editing = { (editing: Bool) -> Void in app?.dispatch(.slide(node.id, SliderProps(props: node.props).value, editing: editing)) }
|
|
530
|
-
|
|
530
|
+
// The children by what `labels` says each is; the value labels sit at
|
|
531
|
+
// the track's ends (the label itself iOS keeps for accessibility).
|
|
532
|
+
let part = { (name: String) -> ElementNode? in
|
|
533
|
+
guard let index = props.labels.firstIndex(of: name), node.children.indices.contains(index) else { return nil }
|
|
534
|
+
return node.children[index]
|
|
535
|
+
}
|
|
536
|
+
let low = part("min"), high = part("max")
|
|
537
|
+
if low != nil || high != nil {
|
|
538
|
+
let label = part("label")
|
|
539
|
+
let labelView = { label.map { AnyView(ElementView(node: $0)) } ?? AnyView(EmptyView()) }
|
|
540
|
+
let lowView = { low.map { AnyView(ElementView(node: $0)) } ?? AnyView(EmptyView()) }
|
|
541
|
+
let highView = { high.map { AnyView(ElementView(node: $0)) } ?? AnyView(EmptyView()) }
|
|
542
|
+
if let step = props.step, step > 0 {
|
|
543
|
+
Slider(value: value, in: range, step: step, label: labelView, minimumValueLabel: lowView, maximumValueLabel: highView, onEditingChanged: editing)
|
|
544
|
+
} else {
|
|
545
|
+
Slider(value: value, in: range, label: labelView, minimumValueLabel: lowView, maximumValueLabel: highView, onEditingChanged: editing)
|
|
546
|
+
}
|
|
547
|
+
} else if node.children.isEmpty {
|
|
531
548
|
if let step = props.step, step > 0 {
|
|
532
549
|
Slider(value: value, in: range, step: step, onEditingChanged: editing)
|
|
533
550
|
} else {
|
|
@@ -156,6 +156,7 @@ struct ListRowsView: View {
|
|
|
156
156
|
/// children are view headers.
|
|
157
157
|
struct SectionElement: View {
|
|
158
158
|
let node: ElementNode
|
|
159
|
+
@Environment(\.nativeApp) private var app
|
|
159
160
|
|
|
160
161
|
var body: some View {
|
|
161
162
|
let props = SectionProps(props: node.props)
|
|
@@ -164,7 +165,24 @@ struct SectionElement: View {
|
|
|
164
165
|
let rows = node.children.filter { $0.kind != "sectionheader" && $0.kind != "sectionfooter" }
|
|
165
166
|
let title = props.header.flatMap { $0.isEmpty ? nil : $0 }
|
|
166
167
|
let note = props.footer.flatMap { $0.isEmpty ? nil : $0 }
|
|
167
|
-
if
|
|
168
|
+
if props.collapsible, #available(iOS 17, macOS 14, *) {
|
|
169
|
+
// `Section(isExpanded:)`: SwiftUI collapses it in a sidebar-style
|
|
170
|
+
// list and shows it whole elsewhere; the chevron's tap goes to the
|
|
171
|
+
// app, which flips the binding and sends `expanded` back.
|
|
172
|
+
let expanded = Binding<Bool>(
|
|
173
|
+
get: { SectionProps(props: node.props).expanded },
|
|
174
|
+
set: { _ in app?.dispatch(.tap(node.id)) }
|
|
175
|
+
)
|
|
176
|
+
Section(isExpanded: expanded) {
|
|
177
|
+
ListRowsView(children: rows)
|
|
178
|
+
} header: {
|
|
179
|
+
if let header {
|
|
180
|
+
ChildrenView(node: header)
|
|
181
|
+
} else if let title {
|
|
182
|
+
Text(verbatim: title)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} else if header != nil || footer != nil {
|
|
168
186
|
// View headers and footers (`sectionheader` / `sectionfooter` children).
|
|
169
187
|
Section {
|
|
170
188
|
ListRowsView(children: rows)
|
|
@@ -194,6 +212,28 @@ struct SectionElement: View {
|
|
|
194
212
|
}
|
|
195
213
|
}
|
|
196
214
|
|
|
215
|
+
/// `disclosuregroup`: SwiftUI's own, its label the first child, its content
|
|
216
|
+
/// the rest; the chevron's tap goes to the app, which flips the binding and
|
|
217
|
+
/// sends `expanded` back.
|
|
218
|
+
struct DisclosureGroupElement: View {
|
|
219
|
+
let node: ElementNode
|
|
220
|
+
@Environment(\.nativeApp) private var app
|
|
221
|
+
|
|
222
|
+
var body: some View {
|
|
223
|
+
let expanded = Binding<Bool>(
|
|
224
|
+
get: { DisclosureGroupProps(props: node.props).expanded },
|
|
225
|
+
set: { _ in app?.dispatch(.tap(node.id)) }
|
|
226
|
+
)
|
|
227
|
+
DisclosureGroup(isExpanded: expanded) {
|
|
228
|
+
ForEach(Array(node.children.dropFirst())) { ElementView(node: $0) }
|
|
229
|
+
} label: {
|
|
230
|
+
if let label = node.children.first {
|
|
231
|
+
ElementView(node: label)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
197
237
|
/// `listrow`: the row's content with its edit-mode hints; a selectable
|
|
198
238
|
/// row is the list's selection's tag (`ListElement`), or under the shim
|
|
199
239
|
/// sends `tap` itself.
|
|
@@ -81,7 +81,9 @@ struct DatePickerElement: View {
|
|
|
81
81
|
/// long press sends `longpress`, and two fingers spreading (`magnify`) or
|
|
82
82
|
/// turning (`rotate`) send `pinch` events. The pinches go alongside the
|
|
83
83
|
/// view's other gestures (`simultaneousGesture`), so a canvas's one-finger
|
|
84
|
-
/// pan inside a two-finger zoom does not keep the zoom from starting
|
|
84
|
+
/// pan inside a two-finger zoom does not keep the zoom from starting; on
|
|
85
|
+
/// iOS 18 they are read from the fingers (PinchTracker.swift), so they begin
|
|
86
|
+
/// as soon as the fingers move. The
|
|
85
87
|
/// drag and the long press attach as `mode` says (`.gesture`,
|
|
86
88
|
/// `.highPriorityGesture` or `.simultaneousGesture`), and the drag's points
|
|
87
89
|
/// are in `coordinateSpace` (`.local` or `.global`).
|
|
@@ -117,6 +119,75 @@ struct GestureElement: View {
|
|
|
117
119
|
to: dragged, mode: props.mode
|
|
118
120
|
)
|
|
119
121
|
: dragged
|
|
122
|
+
#if os(iOS)
|
|
123
|
+
if props.magnify || props.rotate {
|
|
124
|
+
if #available(iOS 18.0, *) {
|
|
125
|
+
fingersPinch(pressed, props: props)
|
|
126
|
+
} else {
|
|
127
|
+
systemPinch(pressed, props: props)
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
pressed
|
|
131
|
+
}
|
|
132
|
+
#else
|
|
133
|
+
systemPinch(pressed, props: props)
|
|
134
|
+
#endif
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// The pinch from the fingers themselves (iOS 18): `SpatialEventGesture`
|
|
138
|
+
/// reports every finger, and PinchTracker.swift takes the scale, the
|
|
139
|
+
/// rotation and the midpoint from the first two, beginning as soon as
|
|
140
|
+
/// the thresholds pass (on the first move with none), as the web
|
|
141
|
+
/// renderer and Photos do. SwiftUI's `MagnifyGesture` begins only once
|
|
142
|
+
/// the system's pinch recognizer has made up its mind, a good way into
|
|
143
|
+
/// the spread.
|
|
144
|
+
#if os(iOS)
|
|
145
|
+
@available(iOS 18.0, *)
|
|
146
|
+
private func fingersPinch(_ view: AnyView, props: GestureProps) -> some View {
|
|
147
|
+
let fingers = fingers
|
|
148
|
+
let tracker: PinchTracker = {
|
|
149
|
+
if let current = fingers.tracker, current.magnify == props.magnify, current.rotate == props.rotate,
|
|
150
|
+
current.minimumScaleDelta == max(0, props.minimumScaleDelta), current.minimumAngleDelta == max(0, props.minimumAngleDelta) {
|
|
151
|
+
return current
|
|
152
|
+
}
|
|
153
|
+
let made = PinchTracker(magnify: props.magnify, rotate: props.rotate, minimumScaleDelta: props.minimumScaleDelta, minimumAngleDelta: props.minimumAngleDelta)
|
|
154
|
+
fingers.tracker = made
|
|
155
|
+
return made
|
|
156
|
+
}()
|
|
157
|
+
return view
|
|
158
|
+
.onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { size in fingers.size = size }
|
|
159
|
+
.simultaneousGesture(
|
|
160
|
+
SpatialEventGesture()
|
|
161
|
+
.onChanged { events in
|
|
162
|
+
let reported = events.map { PinchTracker.Finger(id: AnyHashable($0.id), location: $0.location, active: $0.phase == .active) }
|
|
163
|
+
let cancelled = events.contains { $0.phase == .cancelled }
|
|
164
|
+
let time = events.map(\.timestamp).max() ?? Date().timeIntervalSinceReferenceDate
|
|
165
|
+
send(tracker.update(fingers: reported, cancelled: cancelled, at: time), size: fingers.size)
|
|
166
|
+
}
|
|
167
|
+
.onEnded { events in
|
|
168
|
+
send(tracker.end(cancelled: events.contains { $0.phase == .cancelled }), size: fingers.size)
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private func send(_ report: PinchTracker.Report?, size: CGSize) {
|
|
174
|
+
guard let report else { return }
|
|
175
|
+
let anchor = (
|
|
176
|
+
x: size.width > 0 ? Double(report.startLocation.x / size.width) : 0.5,
|
|
177
|
+
y: size.height > 0 ? Double(report.startLocation.y / size.height) : 0.5
|
|
178
|
+
)
|
|
179
|
+
app?.dispatch(.pinch(
|
|
180
|
+
node.id, phase: report.phase, scale: report.scale, rotation: report.rotation, scaleVelocity: report.scaleVelocity, rotationVelocity: report.rotationVelocity,
|
|
181
|
+
startLocation: (Double(report.startLocation.x), Double(report.startLocation.y)), startAnchor: anchor,
|
|
182
|
+
location: (Double(report.location.x), Double(report.location.y))
|
|
183
|
+
))
|
|
184
|
+
}
|
|
185
|
+
#endif
|
|
186
|
+
|
|
187
|
+
/// SwiftUI's own `MagnifyGesture` and `RotateGesture` (before iOS 18, and
|
|
188
|
+
/// on the Mac, where a trackpad pinch is no finger), with the fingers
|
|
189
|
+
/// tracked for the pinch's location where the system reports them.
|
|
190
|
+
private func systemPinch(_ pressed: AnyView, props: GestureProps) -> AnyView {
|
|
120
191
|
let tracked = props.magnify || props.rotate ? trackingFingers(pressed) : pressed
|
|
121
192
|
let magnified: AnyView = props.magnify
|
|
122
193
|
? AnyView(tracked.simultaneousGesture(
|
|
@@ -132,22 +203,19 @@ struct GestureElement: View {
|
|
|
132
203
|
}
|
|
133
204
|
))
|
|
134
205
|
: tracked
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
} else {
|
|
149
|
-
magnified
|
|
150
|
-
}
|
|
206
|
+
guard props.rotate else { return magnified }
|
|
207
|
+
return AnyView(magnified.simultaneousGesture(
|
|
208
|
+
RotateGesture(minimumAngleDelta: .degrees(props.minimumAngleDelta))
|
|
209
|
+
.onChanged { value in
|
|
210
|
+
let phase = rotating ? "changed" : "began"
|
|
211
|
+
rotating = true
|
|
212
|
+
sendPinch(phase: phase, scale: 1, rotation: value.rotation.degrees, scaleVelocity: 0, rotationVelocity: value.velocity.degrees, startLocation: value.startLocation, startAnchor: value.startAnchor)
|
|
213
|
+
}
|
|
214
|
+
.onEnded { value in
|
|
215
|
+
rotating = false
|
|
216
|
+
sendPinch(phase: "ended", scale: 1, rotation: value.rotation.degrees, scaleVelocity: 0, rotationVelocity: value.velocity.degrees, startLocation: value.startLocation, startAnchor: value.startAnchor)
|
|
217
|
+
}
|
|
218
|
+
))
|
|
151
219
|
}
|
|
152
220
|
|
|
153
221
|
/// The gesture on the view as `mode` composes it with the view's others and its subviews'.
|
|
@@ -224,6 +292,9 @@ final class Fingers {
|
|
|
224
292
|
}
|
|
225
293
|
var midpoint: CGPoint?
|
|
226
294
|
var pinch: Pinch?
|
|
295
|
+
/// iOS 18: the pinch taken from the fingers, and the element's size for its anchor.
|
|
296
|
+
var tracker: PinchTracker?
|
|
297
|
+
var size: CGSize = .zero
|
|
227
298
|
}
|
|
228
299
|
|
|
229
300
|
/// `viewthatfits`: the children are the candidates, in order.
|