@swiftbrowser/swift 0.0.0-stage → 0.1.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/Package.swift +23 -0
- package/README.md +12 -2
- package/Sources/Charts/Charts.swift +65 -0
- package/Sources/FoundationBridge/FoundationBridge.swift +77 -0
- package/Sources/FoundationBridge/Locale.swift +86 -0
- package/Sources/FoundationBridge/Timer.swift +48 -0
- package/Sources/FoundationBridge/UITextChecker.swift +30 -0
- package/Sources/FoundationLite/Common.swift +3 -0
- package/Sources/FoundationLite/FoundationLite.swift +171 -0
- package/Sources/FoundationLite/Interaction.swift +146 -0
- package/Sources/FoundationLite/Timer.swift +48 -0
- package/Sources/SwiftUI/API/Accessibility.swift +132 -0
- package/Sources/SwiftUI/API/Alert.swift +221 -0
- package/Sources/SwiftUI/API/Animation.swift +253 -0
- package/Sources/SwiftUI/API/App.swift +57 -0
- package/Sources/SwiftUI/API/Color.swift +161 -0
- package/Sources/SwiftUI/API/Controls.swift +268 -0
- package/Sources/SwiftUI/API/CustomLayout.swift +403 -0
- package/Sources/SwiftUI/API/Decorations.swift +92 -0
- package/Sources/SwiftUI/API/EditMode.swift +269 -0
- package/Sources/SwiftUI/API/Effects.swift +290 -0
- package/Sources/SwiftUI/API/Environment.swift +355 -0
- package/Sources/SwiftUI/API/Focus.swift +129 -0
- package/Sources/SwiftUI/API/Font.swift +131 -0
- package/Sources/SwiftUI/API/Gauge.swift +289 -0
- package/Sources/SwiftUI/API/Geometry.swift +89 -0
- package/Sources/SwiftUI/API/GeometryReader.swift +124 -0
- package/Sources/SwiftUI/API/Gestures.swift +342 -0
- package/Sources/SwiftUI/API/Grid.swift +188 -0
- package/Sources/SwiftUI/API/LabelStyle.swift +117 -0
- package/Sources/SwiftUI/API/Layout.swift +165 -0
- package/Sources/SwiftUI/API/Lists.swift +276 -0
- package/Sources/SwiftUI/API/Menu.swift +51 -0
- package/Sources/SwiftUI/API/Modifiers.swift +243 -0
- package/Sources/SwiftUI/API/Navigation.swift +371 -0
- package/Sources/SwiftUI/API/ObservableObject.swift +279 -0
- package/Sources/SwiftUI/API/OnChange.swift +48 -0
- package/Sources/SwiftUI/API/Phase8Controls.swift +394 -0
- package/Sources/SwiftUI/API/Picker.swift +176 -0
- package/Sources/SwiftUI/API/Presentation.swift +109 -0
- package/Sources/SwiftUI/API/Primitives.swift +390 -0
- package/Sources/SwiftUI/API/Publishers.swift +107 -0
- package/Sources/SwiftUI/API/RowActions.swift +83 -0
- package/Sources/SwiftUI/API/ScrollTargets.swift +104 -0
- package/Sources/SwiftUI/API/ShapeStyle.swift +311 -0
- package/Sources/SwiftUI/API/State.swift +127 -0
- package/Sources/SwiftUI/API/StrokeStyle.swift +30 -0
- package/Sources/SwiftUI/API/Tabs.swift +119 -0
- package/Sources/SwiftUI/API/Tasks.swift +35 -0
- package/Sources/SwiftUI/API/Timeline.swift +212 -0
- package/Sources/SwiftUI/API/Toolbar.swift +215 -0
- package/Sources/SwiftUI/API/View.swift +207 -0
- package/Sources/SwiftUI/API/ViewModifier.swift +40 -0
- package/Sources/SwiftUI/API/VisualEffect.swift +82 -0
- package/Sources/SwiftUI/Charts/Axis.swift +319 -0
- package/Sources/SwiftUI/Charts/Chart.swift +646 -0
- package/Sources/SwiftUI/Charts/ChartContent.swift +376 -0
- package/Sources/SwiftUI/Charts/Marks.swift +204 -0
- package/Sources/SwiftUI/Charts/Plottable.swift +99 -0
- package/Sources/SwiftUI/Charts/Scales.swift +326 -0
- package/Sources/SwiftUI/Runtime/Executor.swift +83 -0
- package/Sources/SwiftUI/Runtime/Exports.swift +65 -0
- package/Sources/SwiftUI/Runtime/JSON.swift +71 -0
- package/Sources/SwiftUI/Runtime/JSONParser.swift +443 -0
- package/Sources/SwiftUI/Runtime/Ops.swift +106 -0
- package/Sources/SwiftUI/Runtime/Reconciler.swift +210 -0
- package/Sources/SwiftUI/Runtime/Runtime.swift +1367 -0
- package/Sources/_FoundationCommon/Bundle.swift +82 -0
- package/Sources/_FoundationCommon/UserDefaults.swift +170 -0
- package/Sources/_FoundationCommon/_FoundationCommon.swift +10 -0
- package/index.d.ts +2 -0
- package/index.mjs +16 -0
- package/package.json +28 -4
- package/sync-sources.mjs +19 -0
|
@@ -0,0 +1,1367 @@
|
|
|
1
|
+
import Observation
|
|
2
|
+
|
|
3
|
+
#if canImport(WASILibc)
|
|
4
|
+
import WASILibc
|
|
5
|
+
#elseif canImport(Glibc)
|
|
6
|
+
import Glibc
|
|
7
|
+
#elseif canImport(Musl)
|
|
8
|
+
import Musl
|
|
9
|
+
#elseif canImport(Darwin)
|
|
10
|
+
import Darwin
|
|
11
|
+
#endif
|
|
12
|
+
|
|
13
|
+
/// A view that has been mounted. Composite views keep their `State` boxes here
|
|
14
|
+
/// so values survive the parent recreating the view struct.
|
|
15
|
+
@MainActor
|
|
16
|
+
final class _MountedNode {
|
|
17
|
+
var view: any View
|
|
18
|
+
var children: [_MountedNode] = []
|
|
19
|
+
var boxes: [any _AnyStateBox] = []
|
|
20
|
+
/// Identity within a keyed parent (`ForEach`).
|
|
21
|
+
var key: AnyHashable?
|
|
22
|
+
/// Position-and-type path used to restore state across hot reloads.
|
|
23
|
+
var path = ""
|
|
24
|
+
var environment = EnvironmentValues()
|
|
25
|
+
/// Handler supplied by the runtime (navigation links and screens).
|
|
26
|
+
var handler: ((_JSON) -> Void)?
|
|
27
|
+
var navTitle: String?
|
|
28
|
+
var navDisplayMode: String?
|
|
29
|
+
var depth = 0
|
|
30
|
+
var appeared = false
|
|
31
|
+
/// `.animation(_:value:)`: the last tracked value and a counter bumped on change.
|
|
32
|
+
var animationValue: Any?
|
|
33
|
+
var animationToken = 0
|
|
34
|
+
/// Sheets: the mounted sheet content (second child) and its detents.
|
|
35
|
+
var sheetDetents: [String]?
|
|
36
|
+
/// `.task`: the running task and the id it was started for.
|
|
37
|
+
var task: Task<Void, Never>?
|
|
38
|
+
var taskID: AnyHashable?
|
|
39
|
+
var taskStarted = false
|
|
40
|
+
/// `GeometryReader`: the size the renderer last reported.
|
|
41
|
+
var geometrySize = CGSize.zero
|
|
42
|
+
/// Phase 12: this stack is a `.scrollTargetLayout()`; its props carry `scrollTarget`.
|
|
43
|
+
var scrollTargetLayout = false
|
|
44
|
+
/// `TabView` / `Picker`: option tags in order, and the selected index.
|
|
45
|
+
var optionTags: [AnyHashable?] = []
|
|
46
|
+
var selectedIndex = 0
|
|
47
|
+
/// `NavigationStack`: destinations registered by descendants, kept across renders.
|
|
48
|
+
var destinations: [ObjectIdentifier: (Any) -> (any View)?] = [:]
|
|
49
|
+
var pickerOptions: [String] = []
|
|
50
|
+
var pickerLabel = ""
|
|
51
|
+
/// `NavigationSplitView`: the sidebar's `List(selection:)` binding, registered
|
|
52
|
+
/// each pass while the sidebar is reconciled.
|
|
53
|
+
var splitSelection: _ListSelection?
|
|
54
|
+
/// `NavigationStack` nested in another stack's screen: rendered in place.
|
|
55
|
+
var navFlattened = false
|
|
56
|
+
/// `navscreen` / `List`: the edit mode this node owns (`EditButton` toggles it).
|
|
57
|
+
var editMode = EditMode.inactive
|
|
58
|
+
/// `NavigationStack` / compact `NavigationSplitView`: the stack context its
|
|
59
|
+
/// screens and links share. Kept across passes: a clean pass skips the
|
|
60
|
+
/// phase that recomputes `detailShown`, and screen handlers capture it.
|
|
61
|
+
var navContext: _NavContext?
|
|
62
|
+
|
|
63
|
+
// Incremental rendering (Phase 9). A render re-runs a view's body only
|
|
64
|
+
// when the node is `dirty` (its own `@State`, observed object or
|
|
65
|
+
// `@Observable` read changed) or an ancestor re-ran (its view value is
|
|
66
|
+
// new); a clean node whose descendants are all clean is skipped whole,
|
|
67
|
+
// and the elements it produced last time are reused.
|
|
68
|
+
weak var parent: _MountedNode?
|
|
69
|
+
/// This node's body must run again.
|
|
70
|
+
var dirty = true
|
|
71
|
+
/// A descendant is dirty (the chain from the root to every dirty node).
|
|
72
|
+
var subtreeDirty = false
|
|
73
|
+
/// The elements this node produced last time, with the sheets and alerts
|
|
74
|
+
/// its subtree presented (they are collected as side effects).
|
|
75
|
+
var cachedElements: [_Element]?
|
|
76
|
+
var cachedSheets: [_Element] = []
|
|
77
|
+
var cachedAlerts: [_Element] = []
|
|
78
|
+
|
|
79
|
+
init(view: any View) {
|
|
80
|
+
self.view = view
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// The enclosing `NavigationStack`, shared by everything inside it.
|
|
85
|
+
@MainActor
|
|
86
|
+
final class _NavContext {
|
|
87
|
+
var box: _StateBox<[_PushedScreen]>
|
|
88
|
+
var path: _PathBinding?
|
|
89
|
+
/// The stack's mounted node, which owns the destination registry.
|
|
90
|
+
unowned let node: _MountedNode
|
|
91
|
+
|
|
92
|
+
init(box: _StateBox<[_PushedScreen]>, path: _PathBinding?, node: _MountedNode) {
|
|
93
|
+
self.box = box
|
|
94
|
+
self.path = path
|
|
95
|
+
self.node = node
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func push(_ view: any View) { box.value.append(_PushedScreen(view: view)) }
|
|
99
|
+
|
|
100
|
+
/// Pushes a value: onto the path binding when there is one, otherwise as
|
|
101
|
+
/// a resolved destination view.
|
|
102
|
+
func push(value: AnyHashable) {
|
|
103
|
+
if let path {
|
|
104
|
+
path.set(path.get() + [value])
|
|
105
|
+
} else {
|
|
106
|
+
push(destination(for: value))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func register(_ registration: any _DestinationRegistering) {
|
|
111
|
+
node.destinations[registration._destinationType] = { registration._destination(for: $0) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func destination(for value: AnyHashable) -> any View {
|
|
115
|
+
let type = ObjectIdentifier(Swift.type(of: value.base))
|
|
116
|
+
if let builder = node.destinations[type], let view = builder(value.base) { return view }
|
|
117
|
+
return Text("No navigationDestination(for: \(Swift.type(of: value.base)).self)")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Compact `NavigationSplitView`: set to the split view's node. While
|
|
121
|
+
/// `detailShown`, depth 1 is the detail screen, whose pop clears the
|
|
122
|
+
/// sidebar's selection; pushed screens follow it.
|
|
123
|
+
var splitNode: _MountedNode?
|
|
124
|
+
var detailShown = false
|
|
125
|
+
|
|
126
|
+
/// Pops so that `depth` screens below the popped one remain (`depth` 1 pops to the root).
|
|
127
|
+
func pop(to depth: Int) {
|
|
128
|
+
if let splitNode {
|
|
129
|
+
if detailShown, depth <= 1 {
|
|
130
|
+
box.value.removeAll()
|
|
131
|
+
splitNode.splitSelection?.set(nil)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
let pushedDepth = depth - (detailShown ? 1 : 0)
|
|
135
|
+
guard pushedDepth >= 1, box.value.count >= pushedDepth else { return }
|
|
136
|
+
box.value.removeSubrange((pushedDepth - 1)...)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
let pathCount = path?.get().count ?? 0
|
|
140
|
+
if depth <= pathCount, let path {
|
|
141
|
+
path.set(Array(path.get().prefix(depth - 1)))
|
|
142
|
+
box.value.removeAll()
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
let pushedDepth = depth - pathCount
|
|
146
|
+
guard pushedDepth >= 1, box.value.count >= pushedDepth else { return }
|
|
147
|
+
box.value.removeSubrange((pushedDepth - 1)...)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
struct _ReconcileContext {
|
|
152
|
+
var environment: EnvironmentValues
|
|
153
|
+
/// `index:Type/index:Type/...` from the root; keys state snapshots.
|
|
154
|
+
var path: String = ""
|
|
155
|
+
var navStack: _NavContext?
|
|
156
|
+
var screen: _MountedNode?
|
|
157
|
+
/// The nearest enclosing presented sheet's node (for `presentationDetents`).
|
|
158
|
+
var sheet: _MountedNode?
|
|
159
|
+
/// The `NavigationSplitView` whose sidebar is being reconciled: its
|
|
160
|
+
/// `List(selection:)` registers with it.
|
|
161
|
+
var split: _MountedNode?
|
|
162
|
+
/// Inside a `TabView` tab that is not selected: titles stay put.
|
|
163
|
+
var inactiveTab = false
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Owns the mounted tree, schedules renders and routes events.
|
|
167
|
+
@MainActor
|
|
168
|
+
public final class _Runtime {
|
|
169
|
+
public static let shared = _Runtime()
|
|
170
|
+
|
|
171
|
+
private(set) var host: any _RenderHost = _BufferedHost.shared
|
|
172
|
+
private var rootView: (any View)?
|
|
173
|
+
private var root: _MountedNode?
|
|
174
|
+
private let reconciler = _Reconciler()
|
|
175
|
+
private var needsRender = false
|
|
176
|
+
/// The next render re-runs every body (environment changes, `invalidate()`).
|
|
177
|
+
private var fullRender = true
|
|
178
|
+
private var rendering = false
|
|
179
|
+
private var pendingAppear: [@MainActor () -> Void] = []
|
|
180
|
+
/// Values the renderer controls, such as the color scheme.
|
|
181
|
+
private var baseEnvironment = EnvironmentValues()
|
|
182
|
+
/// `.preferredColorScheme` seen in the last pass (sticky), and what the host was told.
|
|
183
|
+
private var preferredColorScheme: ColorScheme?
|
|
184
|
+
private var sentColorScheme: ColorScheme?
|
|
185
|
+
/// State values from a previous build, applied as views first mount.
|
|
186
|
+
private var restoredState: [String: _JSON] = [:]
|
|
187
|
+
/// Animation for the next render pass, set by `withAnimation`. Shared by
|
|
188
|
+
/// every runtime instance because `withAnimation` is a free function.
|
|
189
|
+
private static var transactionAnimation: Animation?
|
|
190
|
+
/// Sheet elements collected during the current element pass, emitted after the tree.
|
|
191
|
+
private var presentedSheets: [_Element] = []
|
|
192
|
+
/// Alert elements collected during the current element pass, emitted after
|
|
193
|
+
/// the sheets so that an alert presented from a sheet covers it.
|
|
194
|
+
private var presentedAlerts: [_Element] = []
|
|
195
|
+
|
|
196
|
+
init() {}
|
|
197
|
+
|
|
198
|
+
// MARK: Entry points
|
|
199
|
+
|
|
200
|
+
/// Called from `App.main()`.
|
|
201
|
+
public func launch<A: App>(_ app: A) {
|
|
202
|
+
guard let scene = app.body as? any _RootScene else {
|
|
203
|
+
fatalError("\(A.Body.self) is not a scene the runtime can present. Use WindowGroup.")
|
|
204
|
+
}
|
|
205
|
+
let headless = _environment("SB_HEADLESS") == "1"
|
|
206
|
+
if let state = _environment("SB_STATE"), let json = try? _JSON.parse(state), case .object(let values) = json {
|
|
207
|
+
restoredState = values
|
|
208
|
+
}
|
|
209
|
+
mount(scene._rootView, host: headless ? _HeadlessHost() : _BufferedHost.shared)
|
|
210
|
+
|
|
211
|
+
if headless {
|
|
212
|
+
replay(events: _environment("SB_EVENTS") ?? "")
|
|
213
|
+
if let limit = _environment("SB_RUN_MS").flatMap(Double.init), limit > 0 {
|
|
214
|
+
runHeadlessClock(upTo: limit)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// Sends the app's defaults to the host (a `defaults` op), which keeps them
|
|
220
|
+
/// for the next launch. Called by `UserDefaults` (Sources/_FoundationCommon).
|
|
221
|
+
public func storeDefaults(_ values: _JSON) {
|
|
222
|
+
host.apply([.defaults(values)])
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// Drives the executor with a virtual clock: runs jobs, jumps to the next
|
|
226
|
+
/// timer, and stops when none remain or the clock would pass `limit` ms.
|
|
227
|
+
private func runHeadlessClock(upTo limit: Double) {
|
|
228
|
+
let executor = _HostExecutor.shared
|
|
229
|
+
var guardCount = 0
|
|
230
|
+
while guardCount < 10_000 {
|
|
231
|
+
guardCount += 1
|
|
232
|
+
let next = executor.runJobs()
|
|
233
|
+
renderIfNeeded()
|
|
234
|
+
if next < 0 { break }
|
|
235
|
+
let nextMs = Double(executor.nowNs + UInt64(next)) / 1_000_000
|
|
236
|
+
if nextMs > limit { break }
|
|
237
|
+
executor.nowNs += UInt64(next)
|
|
238
|
+
host.apply([.event(.object(["type": .string("clock"), "ms": .number(nextMs.rounded())]))])
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// Mounts `view` as the root and performs the first render.
|
|
243
|
+
func mount(_ view: any View, host: any _RenderHost) {
|
|
244
|
+
self.host = host
|
|
245
|
+
rootView = view
|
|
246
|
+
root = nil
|
|
247
|
+
render()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// A tap on the element with this id.
|
|
251
|
+
public func dispatch(tap id: Int) {
|
|
252
|
+
dispatch(event: .object(["type": .string("tap"), "id": .number(Double(id))]))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/// Handles one renderer event (see docs/ops-protocol.md) and renders if needed.
|
|
256
|
+
public func dispatch(event: _JSON) {
|
|
257
|
+
handle(event)
|
|
258
|
+
renderIfNeeded()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/// Applies an event's state changes without rendering. A `batch` event
|
|
262
|
+
/// (`{"type": "batch", "events": [...]}`) applies each of its events, so
|
|
263
|
+
/// one host layout pass that measured several `geometry` and `layout`
|
|
264
|
+
/// elements costs one render instead of one per element.
|
|
265
|
+
private func handle(_ event: _JSON) {
|
|
266
|
+
guard case .object(let fields) = event, case .string(let type)? = fields["type"] else { return }
|
|
267
|
+
let id: Int? = if case .number(let n)? = fields["id"] { Int(n) } else { nil }
|
|
268
|
+
|
|
269
|
+
switch type {
|
|
270
|
+
case "batch":
|
|
271
|
+
if case .array(let events)? = fields["events"] { events.forEach(handle) }
|
|
272
|
+
case "tap":
|
|
273
|
+
if let id { reconciler.handlers[id]?(.null) }
|
|
274
|
+
case "text", "toggle", "select", "step", "slide", "date":
|
|
275
|
+
if let id, let value = fields["value"] { reconciler.handlers[id]?(value) }
|
|
276
|
+
case "geometry":
|
|
277
|
+
if let id, let width = fields["width"], let height = fields["height"] {
|
|
278
|
+
reconciler.handlers[id]?(.object(["width": width, "height": height]))
|
|
279
|
+
}
|
|
280
|
+
case "layout":
|
|
281
|
+
// Host measurement of a custom `Layout`'s children; the handler
|
|
282
|
+
// parses the whole event (see CustomLayout.swift).
|
|
283
|
+
if let id { reconciler.handlers[id]?(event) }
|
|
284
|
+
case "drag", "longpress", "delete", "refresh", "focus", "submit":
|
|
285
|
+
// Phase 8: the handler receives the whole event and reads `type`
|
|
286
|
+
// (a `listrow` handles both `tap` and `delete`; Phase 12: a
|
|
287
|
+
// `textfield` handles `text`, `focus` and `submit`).
|
|
288
|
+
if let id { reconciler.handlers[id]?(event) }
|
|
289
|
+
case "environment":
|
|
290
|
+
if case .string(let scheme)? = fields["colorScheme"] {
|
|
291
|
+
baseEnvironment.colorScheme = scheme == "dark" ? .dark : .light
|
|
292
|
+
invalidate()
|
|
293
|
+
}
|
|
294
|
+
if case .string(let size)? = fields["dynamicTypeSize"], let value = DynamicTypeSize(name: size) {
|
|
295
|
+
baseEnvironment.dynamicTypeSize = value
|
|
296
|
+
invalidate()
|
|
297
|
+
}
|
|
298
|
+
if case .string(let name)? = fields["horizontalSizeClass"], let value = UserInterfaceSizeClass(name: name) {
|
|
299
|
+
baseEnvironment.horizontalSizeClass = value
|
|
300
|
+
invalidate()
|
|
301
|
+
}
|
|
302
|
+
if case .string(let name)? = fields["scenePhase"], let value = ScenePhase(name: name) {
|
|
303
|
+
baseEnvironment.scenePhase = value
|
|
304
|
+
invalidate()
|
|
305
|
+
}
|
|
306
|
+
default:
|
|
307
|
+
break
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/// Schedules a render that re-evaluates the whole tree.
|
|
312
|
+
func invalidate() {
|
|
313
|
+
needsRender = true
|
|
314
|
+
fullRender = true
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// Schedules a render that re-evaluates `node`'s subtree (and whatever
|
|
318
|
+
/// else is dirty); everything else is reused.
|
|
319
|
+
func invalidate(_ node: _MountedNode) {
|
|
320
|
+
markDirty(node)
|
|
321
|
+
needsRender = true
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func markDirty(_ node: _MountedNode) {
|
|
325
|
+
node.dirty = true
|
|
326
|
+
node.cachedElements = nil
|
|
327
|
+
var ancestor = node.parent
|
|
328
|
+
while let current = ancestor, !current.subtreeDirty {
|
|
329
|
+
current.subtreeDirty = true
|
|
330
|
+
current.cachedElements = nil
|
|
331
|
+
ancestor = current.parent
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/// Records the animation `withAnimation` wants for the changes that follow.
|
|
336
|
+
func beginTransaction(_ animation: Animation?) {
|
|
337
|
+
Self.transactionAnimation = animation
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/// Advances the executor's clock, runs queued jobs and due timers, renders
|
|
341
|
+
/// if anything changed, and returns milliseconds until the next timer (-1: none).
|
|
342
|
+
public func runJobs(nowMilliseconds: Double) -> Double {
|
|
343
|
+
let executor = _HostExecutor.shared
|
|
344
|
+
executor.nowNs = UInt64(max(0, nowMilliseconds) * 1_000_000)
|
|
345
|
+
let next = executor.runJobs()
|
|
346
|
+
renderIfNeeded()
|
|
347
|
+
return next < 0 ? -1 : Double(next) / 1_000_000
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
func renderIfNeeded() {
|
|
351
|
+
if needsRender, !rendering { render() }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// MARK: Rendering
|
|
355
|
+
|
|
356
|
+
private func render() {
|
|
357
|
+
guard let rootView else { return }
|
|
358
|
+
rendering = true
|
|
359
|
+
needsRender = false
|
|
360
|
+
|
|
361
|
+
let context = _ReconcileContext(environment: baseEnvironment, path: _typeName(of: rootView))
|
|
362
|
+
let force = fullRender
|
|
363
|
+
fullRender = false
|
|
364
|
+
let node = reconcile(root, with: rootView, context: context, force: force)
|
|
365
|
+
root = node
|
|
366
|
+
presentedSheets = []
|
|
367
|
+
presentedAlerts = []
|
|
368
|
+
// Bodies are tracked per node (see `reconcileChildren`); an observable
|
|
369
|
+
// read while producing elements (rare) falls back to a full render.
|
|
370
|
+
let tree = withObservationTracking {
|
|
371
|
+
elements(of: node) + presentedSheets + presentedAlerts
|
|
372
|
+
} onChange: { [weak self] in
|
|
373
|
+
MainActor.assumeIsolated { self?.invalidate() }
|
|
374
|
+
}
|
|
375
|
+
let animation = Self.transactionAnimation?._json
|
|
376
|
+
Self.transactionAnimation = nil
|
|
377
|
+
var ops = reconciler.update(with: tree, animation: animation)
|
|
378
|
+
if preferredColorScheme != sentColorScheme {
|
|
379
|
+
// Phase 12: `.preferredColorScheme` reaches the host as a root update.
|
|
380
|
+
sentColorScheme = preferredColorScheme
|
|
381
|
+
ops.insert(.update(id: 0, props: .object(["colorScheme": preferredColorScheme.map { .string($0 == .dark ? "dark" : "light") } ?? .null])), at: 0)
|
|
382
|
+
}
|
|
383
|
+
host.apply(ops)
|
|
384
|
+
rendering = false
|
|
385
|
+
|
|
386
|
+
let appearing = pendingAppear
|
|
387
|
+
pendingAppear.removeAll()
|
|
388
|
+
for action in appearing { action() }
|
|
389
|
+
renderIfNeeded()
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/// Re-evaluates the mounted tree against `view`, reusing nodes (and their
|
|
393
|
+
/// state) whose view type is unchanged at the same position or key.
|
|
394
|
+
/// `force`: the parent re-ran, so `view` is a new value and this node's
|
|
395
|
+
/// body must run (and its children are forced in turn). Otherwise `view`
|
|
396
|
+
/// is the node's own stored view and the body runs only when the node is
|
|
397
|
+
/// dirty; a clean node with no dirty descendant is returned as is.
|
|
398
|
+
private func reconcile(_ existing: _MountedNode?, with view: any View, context: _ReconcileContext,
|
|
399
|
+
force: Bool) -> _MountedNode {
|
|
400
|
+
if let existing, !force, !existing.dirty, !existing.subtreeDirty {
|
|
401
|
+
return existing
|
|
402
|
+
}
|
|
403
|
+
var context = context
|
|
404
|
+
let node: _MountedNode
|
|
405
|
+
let clean: Bool
|
|
406
|
+
let properties: [Any]?
|
|
407
|
+
let boxes: [any _AnyStateBox]
|
|
408
|
+
let path = context.path
|
|
409
|
+
|
|
410
|
+
if let existing, type(of: existing.view) == type(of: view) {
|
|
411
|
+
node = existing
|
|
412
|
+
clean = !force && !existing.dirty
|
|
413
|
+
if clean {
|
|
414
|
+
// Same view value, same state: no reflection, no body.
|
|
415
|
+
properties = nil
|
|
416
|
+
boxes = existing.boxes
|
|
417
|
+
} else {
|
|
418
|
+
properties = _reflectedProperties(of: view)
|
|
419
|
+
boxes = stateBoxes(in: properties)
|
|
420
|
+
for (old, new) in zip(existing.boxes, boxes) where old !== new {
|
|
421
|
+
new._adopt(old)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
} else {
|
|
425
|
+
node = _MountedNode(view: view)
|
|
426
|
+
clean = false
|
|
427
|
+
properties = _reflectedProperties(of: view)
|
|
428
|
+
boxes = stateBoxes(in: properties)
|
|
429
|
+
restoreState(into: boxes, at: path)
|
|
430
|
+
}
|
|
431
|
+
node.dirty = false
|
|
432
|
+
node.subtreeDirty = false
|
|
433
|
+
if !clean { node.cachedElements = nil }
|
|
434
|
+
node.view = view
|
|
435
|
+
node.path = path
|
|
436
|
+
if !clean {
|
|
437
|
+
node.boxes = boxes
|
|
438
|
+
for box in boxes {
|
|
439
|
+
// Weak: a `StateObject`'s publisher keeps this closure for as long
|
|
440
|
+
// as the object lives, which may be longer than the runtime (tests).
|
|
441
|
+
box._onChange = { [weak self, weak node] in
|
|
442
|
+
if let node { self?.invalidate(node) }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if let modifier = view as? any _EnvironmentModifying {
|
|
448
|
+
modifier._apply(to: &context.environment)
|
|
449
|
+
}
|
|
450
|
+
if let preferred = view as? any _PreferredColorSchemeModifying, !context.inactiveTab {
|
|
451
|
+
preferredColorScheme = preferred._preferredColorScheme
|
|
452
|
+
}
|
|
453
|
+
if context.environment._scrollTargetLayout, view is any _ScrollTargetContainer {
|
|
454
|
+
// This stack's children are the snap targets; nested stacks are not.
|
|
455
|
+
node.scrollTargetLayout = true
|
|
456
|
+
context.environment._scrollTargetLayout = false
|
|
457
|
+
}
|
|
458
|
+
if let title = view as? any _NavigationTitleModifying, let screen = context.screen, !context.inactiveTab {
|
|
459
|
+
if let value = title._title { screen.navTitle = value }
|
|
460
|
+
if let mode = title._displayMode { screen.navDisplayMode = mode }
|
|
461
|
+
}
|
|
462
|
+
if let appear = view as? any _OnAppearing, !node.appeared {
|
|
463
|
+
node.appeared = true
|
|
464
|
+
if let action = appear._action { pendingAppear.append(action) }
|
|
465
|
+
}
|
|
466
|
+
if let detents = view as? any _DetentsModifying, let sheet = context.sheet {
|
|
467
|
+
sheet.sheetDetents = detents._detents
|
|
468
|
+
}
|
|
469
|
+
if let animated = view as? any _AnimatedView {
|
|
470
|
+
if node === existing, animated._changed(since: node.animationValue) {
|
|
471
|
+
node.animationToken += 1
|
|
472
|
+
}
|
|
473
|
+
node.animationValue = animated._value
|
|
474
|
+
}
|
|
475
|
+
if let registration = view as? any _DestinationRegistering {
|
|
476
|
+
context.navStack?.register(registration)
|
|
477
|
+
}
|
|
478
|
+
if let sidebar = view as? _SplitSidebarView {
|
|
479
|
+
context.split = sidebar.split
|
|
480
|
+
}
|
|
481
|
+
if let list = view as? any _ListSelectionProviding {
|
|
482
|
+
if let selection = list._selection {
|
|
483
|
+
context.environment._listSelection = selection
|
|
484
|
+
context.split?.splitSelection = selection
|
|
485
|
+
}
|
|
486
|
+
// A list outside a navigation screen owns its rows' edit mode.
|
|
487
|
+
if context.environment.editMode == nil {
|
|
488
|
+
context.environment.editMode = editModeBinding(for: node)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if let starting = view as? any _TaskStarting {
|
|
492
|
+
if !node.taskStarted || node.taskID != starting._taskID {
|
|
493
|
+
node.task?.cancel()
|
|
494
|
+
node.taskStarted = true
|
|
495
|
+
node.taskID = starting._taskID
|
|
496
|
+
let action = starting._taskAction
|
|
497
|
+
node.task = Task { await action() }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Decide how to produce the children first, because navigation views
|
|
502
|
+
// adjust the environment that must be resolved before any body runs.
|
|
503
|
+
var makeChildren: () -> [any View]
|
|
504
|
+
/// A second batch of children, produced after the first was reconciled
|
|
505
|
+
/// (a compact split view decides on its detail screen from the
|
|
506
|
+
/// selection its sidebar registered).
|
|
507
|
+
var makeMoreChildren: (() -> [any View])?
|
|
508
|
+
var keys: [AnyHashable]?
|
|
509
|
+
node.handler = nil
|
|
510
|
+
node.navFlattened = false
|
|
511
|
+
|
|
512
|
+
if let split = view as? any _NavigationSplitHost {
|
|
513
|
+
// Descendants fill these in as they are reconciled; a clean pass
|
|
514
|
+
// (only some descendants re-run) keeps what the skipped ones set.
|
|
515
|
+
if !clean { node.splitSelection = nil }
|
|
516
|
+
let sidebar = _SplitSidebarView(content: split._sidebar, split: node)
|
|
517
|
+
if context.environment.horizontalSizeClass == .regular {
|
|
518
|
+
// Two columns, each its own stack; they are not nested in a
|
|
519
|
+
// screen even when the split view is, so they are not flattened.
|
|
520
|
+
context.navStack = nil
|
|
521
|
+
context.screen = nil
|
|
522
|
+
let detail = split._detail
|
|
523
|
+
makeChildren = {
|
|
524
|
+
let sidebarStack = NavigationStack {
|
|
525
|
+
_NavigationTitleView(content: AnyView(sidebar), title: nil, displayMode: "large")
|
|
526
|
+
}
|
|
527
|
+
let detailStack: any View = detail is any _NavigationStackView
|
|
528
|
+
? detail : NavigationStack { AnyView(detail) }
|
|
529
|
+
return [sidebarStack, detailStack]
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
// One stack rooted at the sidebar; the detail is pushed while
|
|
533
|
+
// the sidebar's selection is set, and deeper links push after it.
|
|
534
|
+
let nav = navContext(for: node, box: split._pushedBox, path: nil, clean: clean)
|
|
535
|
+
nav.splitNode = node
|
|
536
|
+
context.navStack = nav
|
|
537
|
+
makeChildren = { [_NavScreenView(content: sidebar, depth: 0)] }
|
|
538
|
+
let detail = split._detail
|
|
539
|
+
makeMoreChildren = {
|
|
540
|
+
nav.detailShown = node.splitSelection?.get() != nil
|
|
541
|
+
let base = nav.detailShown ? 1 : 0
|
|
542
|
+
let detailScreens = nav.detailShown ? [_NavScreenView(content: detail, depth: 1)] : []
|
|
543
|
+
let pushedScreens = nav.box.value.enumerated().map { index, pushed in
|
|
544
|
+
_NavScreenView(content: pushed.view, depth: base + index + 1)
|
|
545
|
+
}
|
|
546
|
+
return detailScreens + pushedScreens
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} else if let stack = view as? any _NavigationStackView, context.screen != nil, context.navStack != nil {
|
|
550
|
+
// Nested in another stack's screen: the root renders in place, its
|
|
551
|
+
// destinations and links use the enclosing stack, its path is ignored.
|
|
552
|
+
node.navFlattened = true
|
|
553
|
+
makeChildren = { [stack._root] }
|
|
554
|
+
} else if let stack = view as? any _NavigationStackView {
|
|
555
|
+
let nav = navContext(for: node, box: stack._pushedBox, path: stack._path, clean: clean)
|
|
556
|
+
context.navStack = nav
|
|
557
|
+
makeChildren = {
|
|
558
|
+
let pathScreens = (nav.path?.get() ?? []).enumerated().map { index, value in
|
|
559
|
+
_NavScreenView(content: _PathScreenValue(value: value), depth: index + 1)
|
|
560
|
+
}
|
|
561
|
+
let pushedScreens = nav.box.value.enumerated().map { index, pushed in
|
|
562
|
+
_NavScreenView(content: pushed.view, depth: pathScreens.count + index + 1)
|
|
563
|
+
}
|
|
564
|
+
return [_NavScreenView(content: stack._root, depth: 0)] + pathScreens + pushedScreens
|
|
565
|
+
}
|
|
566
|
+
} else if let screen = view as? _NavScreenView {
|
|
567
|
+
context.screen = node
|
|
568
|
+
// A screen inside an unselected tab still owns its own title.
|
|
569
|
+
context.inactiveTab = false
|
|
570
|
+
if !clean {
|
|
571
|
+
node.navTitle = nil
|
|
572
|
+
node.navDisplayMode = nil
|
|
573
|
+
}
|
|
574
|
+
node.depth = screen.depth
|
|
575
|
+
if screen.depth > 0, let nav = context.navStack {
|
|
576
|
+
let depth = screen.depth
|
|
577
|
+
node.handler = { _ in nav.pop(to: depth) }
|
|
578
|
+
context.environment.dismiss = DismissAction { nav.pop(to: depth) }
|
|
579
|
+
}
|
|
580
|
+
// Each screen owns the edit mode of its content (`EditButton`,
|
|
581
|
+
// `List.editing`), as on iOS.
|
|
582
|
+
context.environment.editMode = editModeBinding(for: node)
|
|
583
|
+
let nav = context.navStack
|
|
584
|
+
makeChildren = {
|
|
585
|
+
// Path screens resolve their value through the stack's
|
|
586
|
+
// registrations, which the root screen filled in just before.
|
|
587
|
+
if let pathValue = screen.content as? _PathScreenValue, let nav {
|
|
588
|
+
return [nav.destination(for: pathValue.value)]
|
|
589
|
+
}
|
|
590
|
+
return [screen.content]
|
|
591
|
+
}
|
|
592
|
+
} else if let link = view as? any _NavigationLinkView {
|
|
593
|
+
let destination = link._destination
|
|
594
|
+
let value = (view as? any _NavigationValueLinkView)?._value
|
|
595
|
+
let nav = context.navStack
|
|
596
|
+
// In a split view's sidebar a value link selects the row of a
|
|
597
|
+
// `List(selection:)` instead of pushing.
|
|
598
|
+
let selection = context.environment._splitSidebar ? context.environment._listSelection : nil
|
|
599
|
+
node.handler = { _ in
|
|
600
|
+
if let value, let selection {
|
|
601
|
+
selection.set(value)
|
|
602
|
+
} else if let value {
|
|
603
|
+
nav?.push(value: value)
|
|
604
|
+
} else {
|
|
605
|
+
nav?.push(destination)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
makeChildren = { (view as? any _HostView)?._content ?? [] }
|
|
609
|
+
} else if let tabs = view as? any _TabViewHost {
|
|
610
|
+
let roots = _flattenChildren(tabs._tabContent)
|
|
611
|
+
node.optionTags = roots.map { _tag(of: $0) }
|
|
612
|
+
node.selectedIndex = tabs._selectedIndex(tags: node.optionTags)
|
|
613
|
+
let tags = node.optionTags
|
|
614
|
+
node.handler = { payload in
|
|
615
|
+
if case .number(let index) = payload { tabs._select(index: Int(index), tags: tags) }
|
|
616
|
+
}
|
|
617
|
+
let selected = node.selectedIndex
|
|
618
|
+
makeChildren = {
|
|
619
|
+
roots.enumerated().map { index, root in
|
|
620
|
+
let label = _tabLabel(of: root)
|
|
621
|
+
return _TabScreenView(content: root,
|
|
622
|
+
title: label.map { _textContent(of: $0) } ?? "",
|
|
623
|
+
systemImage: label.flatMap { _systemImage(of: $0) },
|
|
624
|
+
isSelected: index == selected)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
} else if let tab = view as? _TabScreenView {
|
|
628
|
+
if !tab.isSelected { context.inactiveTab = true }
|
|
629
|
+
makeChildren = { [tab.content] }
|
|
630
|
+
} else if let picker = view as? any _PickerHost {
|
|
631
|
+
let options = _flattenPickerOptions(picker._options)
|
|
632
|
+
node.optionTags = options.map { $0.tag }
|
|
633
|
+
node.selectedIndex = picker._selectedIndex(tags: node.optionTags)
|
|
634
|
+
node.pickerOptions = options.map { _textContent(of: $0.view) }
|
|
635
|
+
node.pickerLabel = _textContent(of: picker._labelView)
|
|
636
|
+
let tags = node.optionTags
|
|
637
|
+
node.handler = { payload in
|
|
638
|
+
if case .number(let index) = payload { picker._select(index: Int(index), tags: tags) }
|
|
639
|
+
}
|
|
640
|
+
makeChildren = { [] }
|
|
641
|
+
} else if let reader = view as? any _GeometryReading {
|
|
642
|
+
let size = node.geometrySize
|
|
643
|
+
node.handler = { [unowned self] payload in
|
|
644
|
+
guard case .object(let fields) = payload,
|
|
645
|
+
case .number(let width)? = fields["width"],
|
|
646
|
+
case .number(let height)? = fields["height"] else { return }
|
|
647
|
+
let reported = CGSize(width: width, height: height)
|
|
648
|
+
if reported != node.geometrySize {
|
|
649
|
+
node.geometrySize = reported
|
|
650
|
+
self.invalidate(node)
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
makeChildren = { [reader._content(for: size)] }
|
|
654
|
+
} else if let sheet = view as? any _SheetPresenting {
|
|
655
|
+
// The base is always a child; the sheet content is a second child
|
|
656
|
+
// while presented, so its state survives re-renders.
|
|
657
|
+
let isPresented = sheet._isPresented
|
|
658
|
+
let onDismiss = sheet._onDismiss
|
|
659
|
+
if !clean { node.sheetDetents = nil }
|
|
660
|
+
node.handler = { _ in
|
|
661
|
+
isPresented.wrappedValue = false
|
|
662
|
+
onDismiss?()
|
|
663
|
+
}
|
|
664
|
+
if isPresented.wrappedValue {
|
|
665
|
+
makeChildren = { [sheet._base, sheet._sheetContent] }
|
|
666
|
+
} else {
|
|
667
|
+
makeChildren = { [sheet._base] }
|
|
668
|
+
}
|
|
669
|
+
} else if let alert = view as? any _AlertPresenting {
|
|
670
|
+
// Like a sheet: the actions are a second child while presented. A
|
|
671
|
+
// tap on the alert itself dismisses without running an action.
|
|
672
|
+
let isPresented = alert._isPresented
|
|
673
|
+
node.handler = { _ in isPresented.wrappedValue = false }
|
|
674
|
+
if isPresented.wrappedValue {
|
|
675
|
+
makeChildren = { [alert._base, alert._alertActions] }
|
|
676
|
+
} else {
|
|
677
|
+
makeChildren = { [alert._base] }
|
|
678
|
+
}
|
|
679
|
+
} else if let group = view as? any _GroupView {
|
|
680
|
+
keys = group._keys
|
|
681
|
+
makeChildren = { group._children }
|
|
682
|
+
} else if let hostView = view as? any _HostView {
|
|
683
|
+
makeChildren = { hostView._content }
|
|
684
|
+
} else {
|
|
685
|
+
makeChildren = { [self.body(of: view)] }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if node.handler == nil, let refreshing = view as? any _Refreshing {
|
|
689
|
+
node.handler = refreshing._refresh(in: context.environment).handler
|
|
690
|
+
}
|
|
691
|
+
if node.handler == nil, let handling = view as? any _EnvironmentHandling {
|
|
692
|
+
node.handler = handling._handler(in: context.environment)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
node.environment = context.environment
|
|
696
|
+
if clean {
|
|
697
|
+
// The body's output is the children already mounted (the view
|
|
698
|
+
// value is unchanged); only the dirty ones among them re-run.
|
|
699
|
+
let kept = node.children
|
|
700
|
+
makeChildren = { kept.map(\.view) }
|
|
701
|
+
makeMoreChildren = nil
|
|
702
|
+
keys = kept.allSatisfy { $0.key != nil } && !kept.isEmpty ? kept.map { $0.key! } : nil
|
|
703
|
+
} else {
|
|
704
|
+
resolveEnvironment(in: properties, with: context.environment, owner: node)
|
|
705
|
+
}
|
|
706
|
+
var phases = [makeChildren]
|
|
707
|
+
if let makeMoreChildren { phases.append(makeMoreChildren) }
|
|
708
|
+
reconcileChildren(of: node, phases: phases, keys: keys, context: context, force: !clean)
|
|
709
|
+
return node
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/// The stack context of a `NavigationStack` / split node, created once and
|
|
713
|
+
/// pointed at the current view's storage on every pass that re-ran the body.
|
|
714
|
+
private func navContext(for node: _MountedNode, box: _StateBox<[_PushedScreen]>, path: _PathBinding?,
|
|
715
|
+
clean: Bool) -> _NavContext {
|
|
716
|
+
if let nav = node.navContext {
|
|
717
|
+
if !clean {
|
|
718
|
+
nav.box = box
|
|
719
|
+
nav.path = path
|
|
720
|
+
}
|
|
721
|
+
return nav
|
|
722
|
+
}
|
|
723
|
+
let nav = _NavContext(box: box, path: path, node: node)
|
|
724
|
+
node.navContext = nav
|
|
725
|
+
return nav
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/// Reconciles the node's children phase by phase: each phase's views are
|
|
729
|
+
/// produced only after the previous phase's children were reconciled.
|
|
730
|
+
/// Keys apply to the first phase's views.
|
|
731
|
+
private func reconcileChildren(of node: _MountedNode, phases: [() -> [any View]],
|
|
732
|
+
keys: [AnyHashable]?, context: _ReconcileContext, force: Bool) {
|
|
733
|
+
let old = node.children
|
|
734
|
+
var oldByKey: [AnyHashable: _MountedNode] = [:]
|
|
735
|
+
for child in old {
|
|
736
|
+
if let key = child.key { oldByKey[key] = child }
|
|
737
|
+
}
|
|
738
|
+
defer {
|
|
739
|
+
let kept = Set(node.children.map(ObjectIdentifier.init))
|
|
740
|
+
for dropped in old where !kept.contains(ObjectIdentifier(dropped)) {
|
|
741
|
+
unmount(dropped)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
var children: [_MountedNode] = []
|
|
746
|
+
for (phase, makeViews) in phases.enumerated() {
|
|
747
|
+
// The body (and a `ForEach`'s row closures) run under observation
|
|
748
|
+
// tracking: an `@Observable` property read here marks this node
|
|
749
|
+
// dirty when it changes, and nothing else re-renders.
|
|
750
|
+
let views: [any View] = force
|
|
751
|
+
? withObservationTracking(makeViews) { [weak self, weak node] in
|
|
752
|
+
MainActor.assumeIsolated { if let node { self?.invalidate(node) } }
|
|
753
|
+
}
|
|
754
|
+
: makeViews()
|
|
755
|
+
for childView in views {
|
|
756
|
+
let index = children.count
|
|
757
|
+
let key = phase == 0 ? keys?[index] : nil
|
|
758
|
+
let existing: _MountedNode?
|
|
759
|
+
if let key {
|
|
760
|
+
existing = oldByKey.removeValue(forKey: key)
|
|
761
|
+
} else if index < old.count, old[index].key == nil {
|
|
762
|
+
existing = old[index]
|
|
763
|
+
} else {
|
|
764
|
+
existing = nil
|
|
765
|
+
}
|
|
766
|
+
var childContext = context
|
|
767
|
+
let segment = key.map { "\($0.base)" } ?? String(index)
|
|
768
|
+
childContext.path += "/\(segment):\(_typeName(of: childView))"
|
|
769
|
+
if index == 1, let sheet = node.view as? any _SheetPresenting {
|
|
770
|
+
let isPresented = sheet._isPresented
|
|
771
|
+
let onDismiss = sheet._onDismiss
|
|
772
|
+
childContext.sheet = node
|
|
773
|
+
childContext.navStack = nil
|
|
774
|
+
childContext.screen = nil
|
|
775
|
+
childContext.environment.dismiss = DismissAction {
|
|
776
|
+
isPresented.wrappedValue = false
|
|
777
|
+
onDismiss?()
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if index == 1, let alert = node.view as? any _AlertPresenting {
|
|
781
|
+
let isPresented = alert._isPresented
|
|
782
|
+
childContext.environment.dismiss = DismissAction { isPresented.wrappedValue = false }
|
|
783
|
+
}
|
|
784
|
+
let child = reconcile(existing, with: childView, context: childContext, force: force)
|
|
785
|
+
child.key = key
|
|
786
|
+
child.parent = node
|
|
787
|
+
children.append(child)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
node.children = children
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/// The `editMode` environment binding a `navscreen` or `List` node owns.
|
|
794
|
+
private func editModeBinding(for node: _MountedNode) -> Binding<EditMode> {
|
|
795
|
+
Binding(
|
|
796
|
+
get: { [weak node] in MainActor.assumeIsolated { node?.editMode ?? .inactive } },
|
|
797
|
+
set: { [weak self, weak node] mode in
|
|
798
|
+
MainActor.assumeIsolated {
|
|
799
|
+
guard let node, node.editMode != mode else { return }
|
|
800
|
+
node.editMode = mode
|
|
801
|
+
self?.invalidate(node)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/// Cancels tasks in a subtree that left the tree.
|
|
808
|
+
private func unmount(_ node: _MountedNode) {
|
|
809
|
+
node.task?.cancel()
|
|
810
|
+
node.task = nil
|
|
811
|
+
node.children.forEach(unmount)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private func body(of view: any View) -> any View {
|
|
815
|
+
func open<V: View>(_ view: V) -> any View { view.body }
|
|
816
|
+
return open(view)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/// The property-wrapper values of a view that the runtime acts on
|
|
820
|
+
/// (`@State`, `@StateObject`, `@ObservedObject`, `@Environment`,
|
|
821
|
+
/// `@EnvironmentObject`), or `nil` when its type declares none.
|
|
822
|
+
///
|
|
823
|
+
/// Reflection is the most expensive step of a render, and most views in a
|
|
824
|
+
/// tree are the shim's own modifiers and primitives, which declare no
|
|
825
|
+
/// such properties. The first instance of each type is reflected; when
|
|
826
|
+
/// none of its stored properties is one of those wrappers the type is
|
|
827
|
+
/// remembered as plain and never reflected again. The wrappers are
|
|
828
|
+
/// concrete struct types, so whether a stored property is one is a
|
|
829
|
+
/// property of the type, not of the instance.
|
|
830
|
+
private func _reflectedProperties(of view: any View) -> [Any]? {
|
|
831
|
+
let key = ObjectIdentifier(type(of: view))
|
|
832
|
+
if let plain = plainTypes[key], plain { return nil }
|
|
833
|
+
var values = Mirror(reflecting: view).children.map(\.value)
|
|
834
|
+
// `ModifiedContent` stores its `ViewModifier`, whose own `@State` and
|
|
835
|
+
// `@Environment` properties are managed like the view's.
|
|
836
|
+
for value in values where value is any ViewModifier {
|
|
837
|
+
values += Mirror(reflecting: value).children.map(\.value)
|
|
838
|
+
}
|
|
839
|
+
if plainTypes[key] == nil {
|
|
840
|
+
let relevant = values.contains { value in
|
|
841
|
+
value is any _StateProperty || value is any _EnvironmentProperty || value is any _ObservingProperty
|
|
842
|
+
}
|
|
843
|
+
plainTypes[key] = !relevant
|
|
844
|
+
if !relevant { return nil }
|
|
845
|
+
}
|
|
846
|
+
return values
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/// Per view type: `true` when it declares no runtime-managed properties.
|
|
850
|
+
private var plainTypes: [ObjectIdentifier: Bool] = [:]
|
|
851
|
+
/// Per view type: its name without generic arguments.
|
|
852
|
+
private var typeNames: [ObjectIdentifier: String] = [:]
|
|
853
|
+
|
|
854
|
+
private func stateBoxes(in properties: [Any]?) -> [any _AnyStateBox] {
|
|
855
|
+
guard let properties else { return [] }
|
|
856
|
+
return properties.compactMap { ($0 as? any _StateProperty)?._box }
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/// Resolves `@Environment`/`@EnvironmentObject` properties and subscribes
|
|
860
|
+
/// `@ObservedObject`/`@EnvironmentObject` properties so that their
|
|
861
|
+
/// object's `objectWillChange` invalidates this runtime. Resolution runs
|
|
862
|
+
/// first because an environment object is observed through its resolved value.
|
|
863
|
+
private func resolveEnvironment(in properties: [Any]?, with environment: EnvironmentValues, owner node: _MountedNode) {
|
|
864
|
+
guard let properties else { return }
|
|
865
|
+
for value in properties {
|
|
866
|
+
(value as? any _EnvironmentProperty)?._resolve(in: environment)
|
|
867
|
+
(value as? any _ObservingProperty)?._observe(owner: node) { [weak self, weak node] in
|
|
868
|
+
if let node { self?.invalidate(node) }
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
private func _typeName(of view: any View) -> String {
|
|
874
|
+
let key = ObjectIdentifier(type(of: view))
|
|
875
|
+
if let cached = typeNames[key] { return cached }
|
|
876
|
+
let full = String(describing: type(of: view))
|
|
877
|
+
let name = if let angle = full.firstIndex(of: "<") { String(full[..<angle]) } else { full }
|
|
878
|
+
typeNames[key] = name
|
|
879
|
+
return name
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// MARK: Elements
|
|
883
|
+
|
|
884
|
+
private func elements(of node: _MountedNode) -> [_Element] {
|
|
885
|
+
if let cached = node.cachedElements {
|
|
886
|
+
presentedSheets += node.cachedSheets
|
|
887
|
+
presentedAlerts += node.cachedAlerts
|
|
888
|
+
return cached
|
|
889
|
+
}
|
|
890
|
+
let sheetsBefore = presentedSheets.count
|
|
891
|
+
let alertsBefore = presentedAlerts.count
|
|
892
|
+
let produced = produceElements(of: node)
|
|
893
|
+
node.cachedElements = produced
|
|
894
|
+
node.cachedSheets = Array(presentedSheets[sheetsBefore...])
|
|
895
|
+
node.cachedAlerts = Array(presentedAlerts[alertsBefore...])
|
|
896
|
+
return produced
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private func produceElements(of node: _MountedNode) -> [_Element] {
|
|
900
|
+
var produced: [_Element]
|
|
901
|
+
if node.view is _NavScreenView {
|
|
902
|
+
// `.toolbar` / `.searchable` emit `toolbar` and `searchfield`
|
|
903
|
+
// elements wherever they were applied; the renderer wants them as
|
|
904
|
+
// the screen's own children, after the content.
|
|
905
|
+
var bar: [_Element] = []
|
|
906
|
+
let content = node.children.flatMap(elements(of:)).map { _hoistBarItems(from: $0, into: &bar) }
|
|
907
|
+
produced = [_Element(
|
|
908
|
+
kind: "navscreen",
|
|
909
|
+
props: .object([
|
|
910
|
+
"title": .string(node.navTitle ?? ""),
|
|
911
|
+
"displayMode": .string(node.navDisplayMode ?? "automatic"),
|
|
912
|
+
"depth": .number(Double(node.depth)),
|
|
913
|
+
]),
|
|
914
|
+
children: content + bar,
|
|
915
|
+
handler: node.handler
|
|
916
|
+
)]
|
|
917
|
+
} else if let sheet = node.view as? any _SheetPresenting {
|
|
918
|
+
// Base inline; the presented content becomes a root-level `sheet` element.
|
|
919
|
+
produced = node.children.first.map(elements(of:)) ?? []
|
|
920
|
+
if node.children.count > 1 {
|
|
921
|
+
let detents = sheet._fullScreen ? ["full"] : (node.sheetDetents ?? ["large"])
|
|
922
|
+
presentedSheets.append(_Element(
|
|
923
|
+
kind: "sheet",
|
|
924
|
+
props: .object(["detents": .array(detents.map(_JSON.string))]),
|
|
925
|
+
children: elements(of: node.children[1]),
|
|
926
|
+
handler: node.handler,
|
|
927
|
+
key: AnyHashable(_SheetKey(path: node.path))
|
|
928
|
+
))
|
|
929
|
+
}
|
|
930
|
+
} else if let alert = node.view as? any _AlertPresenting {
|
|
931
|
+
// Base inline; the actions become a root-level `alert` element whose
|
|
932
|
+
// buttons also dismiss when tapped.
|
|
933
|
+
produced = node.children.first.map(elements(of:)) ?? []
|
|
934
|
+
if node.children.count > 1 {
|
|
935
|
+
let isPresented = alert._isPresented
|
|
936
|
+
let buttons = elements(of: node.children[1]).map { _dismissing($0, with: isPresented) }
|
|
937
|
+
presentedAlerts.append(_Element(
|
|
938
|
+
kind: "alert",
|
|
939
|
+
props: .object([
|
|
940
|
+
"title": .string(alert._alertTitle),
|
|
941
|
+
"message": alert._alertMessage.map(_JSON.string) ?? .null,
|
|
942
|
+
"style": .string(alert._alertStyle),
|
|
943
|
+
]),
|
|
944
|
+
children: buttons,
|
|
945
|
+
handler: node.handler,
|
|
946
|
+
key: AnyHashable(_AlertKey(path: node.path))
|
|
947
|
+
))
|
|
948
|
+
}
|
|
949
|
+
} else if node.view is any _NavigationSplitHost {
|
|
950
|
+
// Regular: `navsplit` over the two column stacks. Compact: one
|
|
951
|
+
// `navstack` whose screens are the sidebar, the detail and what
|
|
952
|
+
// the detail pushed.
|
|
953
|
+
let regular = node.environment.horizontalSizeClass == .regular
|
|
954
|
+
produced = [_Element(
|
|
955
|
+
kind: regular ? "navsplit" : "navstack",
|
|
956
|
+
props: .object([:]),
|
|
957
|
+
children: node.children.flatMap(elements(of:)),
|
|
958
|
+
handler: nil
|
|
959
|
+
)]
|
|
960
|
+
} else if node.view is any _NavigationStackView, node.navFlattened {
|
|
961
|
+
produced = node.children.flatMap(elements(of:))
|
|
962
|
+
} else if node.view is any _TabViewHost {
|
|
963
|
+
produced = [_Element(
|
|
964
|
+
kind: "tabview",
|
|
965
|
+
props: .object(["selected": .number(Double(node.selectedIndex))]),
|
|
966
|
+
children: node.children.flatMap(elements(of:)),
|
|
967
|
+
handler: node.handler
|
|
968
|
+
)]
|
|
969
|
+
} else if node.view is any _PickerHost {
|
|
970
|
+
produced = [_Element(
|
|
971
|
+
kind: "picker",
|
|
972
|
+
props: .object([
|
|
973
|
+
"style": .string(node.environment._pickerStyle),
|
|
974
|
+
"label": .string(node.pickerLabel),
|
|
975
|
+
"options": .array(node.pickerOptions.map(_JSON.string)),
|
|
976
|
+
"selected": .number(Double(node.selectedIndex)),
|
|
977
|
+
]),
|
|
978
|
+
children: [],
|
|
979
|
+
handler: node.handler
|
|
980
|
+
)]
|
|
981
|
+
} else if let animated = node.view as? any _AnimatedView {
|
|
982
|
+
var style: [String: _JSON] = ["animationToken": .number(Double(node.animationToken))]
|
|
983
|
+
if let animation = animated._animation { style["animation"] = animation._json }
|
|
984
|
+
produced = [_Element(
|
|
985
|
+
kind: "styled",
|
|
986
|
+
props: .object(["style": .object(style)]),
|
|
987
|
+
children: node.children.flatMap(elements(of:)),
|
|
988
|
+
handler: nil
|
|
989
|
+
)]
|
|
990
|
+
} else if !(node.view is any _GroupView), let hostView = node.view as? any _HostView {
|
|
991
|
+
// Groups are checked first: casting an `Optional<SomeHostView>` to
|
|
992
|
+
// `_HostView` unwraps the optional, which would misclassify it.
|
|
993
|
+
let kind = hostView._kind
|
|
994
|
+
var children = childElements(of: node)
|
|
995
|
+
if kind == "list" { children = wrapListRows(children, in: node) }
|
|
996
|
+
var props = hostView._props(in: node.environment)
|
|
997
|
+
if node.scrollTargetLayout, case .object(var fields) = props {
|
|
998
|
+
// Phase 12: this stack's children are the scroll view's snap targets.
|
|
999
|
+
fields["scrollTarget"] = .bool(true)
|
|
1000
|
+
props = .object(fields)
|
|
1001
|
+
}
|
|
1002
|
+
var element = _Element(
|
|
1003
|
+
kind: kind,
|
|
1004
|
+
props: props,
|
|
1005
|
+
children: children,
|
|
1006
|
+
handler: node.handler ?? hostView._handler
|
|
1007
|
+
)
|
|
1008
|
+
if let link = node.view as? any _NavigationValueLinkView, let value = link._value {
|
|
1009
|
+
element.row = _RowMark(linkValue: value, linkHandler: element.handler)
|
|
1010
|
+
}
|
|
1011
|
+
// Wrappers that leave the row's content as their (first) child pass
|
|
1012
|
+
// its row mark up, so `Text("x").tag(1).padding()` stays selectable.
|
|
1013
|
+
let transparent = kind == "styled" || kind == "gesture" || kind == "tapgesture"
|
|
1014
|
+
? children.count == 1 : kind == "swipeactions" || kind == "contextmenu"
|
|
1015
|
+
if transparent, let inner = children.first?.row {
|
|
1016
|
+
element.row = (element.row ?? _RowMark()).merged(with: inner)
|
|
1017
|
+
}
|
|
1018
|
+
produced = [element]
|
|
1019
|
+
} else {
|
|
1020
|
+
produced = childElements(of: node)
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if let tagged = node.view as? any _TagProviding {
|
|
1024
|
+
let tag = tagged._tag
|
|
1025
|
+
for index in produced.indices where produced[index].row?.tag == nil {
|
|
1026
|
+
produced[index].row = (produced[index].row ?? _RowMark()).merged(with: _RowMark(tag: tag))
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if let disabling = node.view as? any _RowEditingDisabling, disabling._deleteDisabled {
|
|
1030
|
+
for index in produced.indices {
|
|
1031
|
+
produced[index].row = (produced[index].row ?? _RowMark()).merged(with: _RowMark(deleteDisabled: true))
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
if let tab = node.view as? _TabScreenView, !tab.isSelected {
|
|
1035
|
+
// An unselected tab's toolbars do not reach the enclosing screen.
|
|
1036
|
+
var dropped: [_Element] = []
|
|
1037
|
+
produced = produced.map { _hoistBarItems(from: $0, into: &dropped) }
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if let key = node.key {
|
|
1041
|
+
for index in produced.indices where produced[index].key == nil {
|
|
1042
|
+
produced[index].key = AnyHashable(_ElementKey(base: key, index: index))
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return produced
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// MARK: State snapshot (hot reload)
|
|
1049
|
+
|
|
1050
|
+
/// The elements of a node's children, in order. The elements a keyed
|
|
1051
|
+
/// group (`ForEach`) produces carry its ids as keys; two keyed groups
|
|
1052
|
+
/// under the same parent may use the same ids (`ForEach(0..<n)` twice in
|
|
1053
|
+
/// a `ZStack`), so each group's keys are qualified by its position among
|
|
1054
|
+
/// the siblings. Without that the element diff matched the two groups'
|
|
1055
|
+
/// elements to each other on alternate passes, swapping their props.
|
|
1056
|
+
private func childElements(of node: _MountedNode) -> [_Element] {
|
|
1057
|
+
let delete = (node.view as? any _DeletableGroup)?._delete
|
|
1058
|
+
return node.children.enumerated().flatMap { position, child -> [_Element] in
|
|
1059
|
+
var items = elements(of: child)
|
|
1060
|
+
// A `ForEach` id is the row's implicit tag; an `onDelete` group's
|
|
1061
|
+
// child at `position` is the row at that offset of its data.
|
|
1062
|
+
if let key = child.key {
|
|
1063
|
+
for index in items.indices where items[index].row?.tag == nil {
|
|
1064
|
+
items[index].row = (items[index].row ?? _RowMark()).merged(with: _RowMark(tag: key))
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if let delete {
|
|
1068
|
+
for index in items.indices where items[index].row?.delete == nil {
|
|
1069
|
+
items[index].row = (items[index].row ?? _RowMark()).merged(with: _RowMark(delete: { delete(position) }))
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if child.key == nil, let group = child.view as? any _GroupView, group._keys != nil {
|
|
1073
|
+
for index in items.indices {
|
|
1074
|
+
if let key = items[index].key {
|
|
1075
|
+
items[index].key = AnyHashable(_GroupKey(position: position, key: key))
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return items
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/// Wraps a `list`'s rows (also those of its `section`s) that can be selected
|
|
1084
|
+
/// or deleted in `listrow` elements (docs/ops-protocol.md, Phase 8). A row
|
|
1085
|
+
/// is selectable when the list has a selection and the row's `.tag`,
|
|
1086
|
+
/// `ForEach` id or `NavigationLink(value:)` is of the selection's type;
|
|
1087
|
+
/// deletable when its `ForEach` has `onDelete` and the row is not
|
|
1088
|
+
/// `deleteDisabled`. The row's key moves to the wrapper.
|
|
1089
|
+
private func wrapListRows(_ elements: [_Element], in node: _MountedNode) -> [_Element] {
|
|
1090
|
+
let selection = node.environment._listSelection
|
|
1091
|
+
let editMode = node.environment.editMode
|
|
1092
|
+
return elements.map { element in
|
|
1093
|
+
if element.kind == "section" {
|
|
1094
|
+
var section = element
|
|
1095
|
+
section.children = wrapListRows(section.children, in: node)
|
|
1096
|
+
return section
|
|
1097
|
+
}
|
|
1098
|
+
guard let mark = element.row else { return element }
|
|
1099
|
+
let value = mark.linkValue ?? mark.tag
|
|
1100
|
+
let selectable: Bool = if let selection, let value { selection.accepts(value) } else { false }
|
|
1101
|
+
let deletable = mark.delete != nil && !mark.deleteDisabled
|
|
1102
|
+
guard selectable || deletable else { return element }
|
|
1103
|
+
let selected = selectable && selection!.contains(value!)
|
|
1104
|
+
|
|
1105
|
+
var inner = element
|
|
1106
|
+
inner.key = nil
|
|
1107
|
+
inner.row = nil
|
|
1108
|
+
let handler: (_JSON) -> Void = { payload in
|
|
1109
|
+
if case .object(let fields) = payload, case .string("delete")? = fields["type"] {
|
|
1110
|
+
if deletable { mark.delete?() }
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
guard selectable, let selection, let value else {
|
|
1114
|
+
mark.linkHandler?(.null)
|
|
1115
|
+
return
|
|
1116
|
+
}
|
|
1117
|
+
let editing = editMode?.wrappedValue.isEditing ?? false
|
|
1118
|
+
if mark.linkValue != nil, !editing {
|
|
1119
|
+
// A value link pushes (or, in a split sidebar, selects) as before.
|
|
1120
|
+
mark.linkHandler?(.null)
|
|
1121
|
+
} else if selection.isMultiple || editing {
|
|
1122
|
+
selection.toggle(value)
|
|
1123
|
+
} else {
|
|
1124
|
+
selection.set(value)
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return _Element(
|
|
1128
|
+
kind: "listrow",
|
|
1129
|
+
props: .object([
|
|
1130
|
+
"selectable": .bool(selectable),
|
|
1131
|
+
"selected": .bool(selected),
|
|
1132
|
+
"deletable": .bool(deletable),
|
|
1133
|
+
]),
|
|
1134
|
+
children: [inner],
|
|
1135
|
+
handler: handler,
|
|
1136
|
+
key: element.key
|
|
1137
|
+
)
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/// Primitive `@State` values keyed by view path, for `SB_STATE`.
|
|
1142
|
+
public func snapshot() -> _JSON {
|
|
1143
|
+
var values: [String: _JSON] = [:]
|
|
1144
|
+
func walk(_ node: _MountedNode) {
|
|
1145
|
+
for (index, box) in node.boxes.enumerated() {
|
|
1146
|
+
if let json = _primitiveJSON(box._anyValue) {
|
|
1147
|
+
values["\(node.path)#\(index)"] = json
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
node.children.forEach(walk)
|
|
1151
|
+
}
|
|
1152
|
+
if let root { walk(root) }
|
|
1153
|
+
return .object(values)
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
private func restoreState(into boxes: [any _AnyStateBox], at path: String) {
|
|
1157
|
+
guard !restoredState.isEmpty else { return }
|
|
1158
|
+
for (index, box) in boxes.enumerated() {
|
|
1159
|
+
guard let saved = restoredState["\(path)#\(index)"] else { continue }
|
|
1160
|
+
switch (box._anyValue, saved) {
|
|
1161
|
+
case (is Int, .number(let n)) where n == n.rounded(): box._anyValue = Int(n)
|
|
1162
|
+
case (is Double, .number(let n)): box._anyValue = n
|
|
1163
|
+
case (is String, .string(let s)): box._anyValue = s
|
|
1164
|
+
case (is Bool, .bool(let b)): box._anyValue = b
|
|
1165
|
+
default: break
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// MARK: Headless replay
|
|
1171
|
+
|
|
1172
|
+
/// Replays events separated by commas: `tap:12`, `text:7:Milk`,
|
|
1173
|
+
/// `toggle:9:true`, `env:dark`, `size:regular`, `step:7:1`, `slide:7:0.35`,
|
|
1174
|
+
/// `date:7:1700000000`, `drag:12:changed:40x-3`, `longpress:12`, `delete:31`,
|
|
1175
|
+
/// `refresh:40`. Each is echoed before its ops.
|
|
1176
|
+
func replay(events: String) {
|
|
1177
|
+
for raw in events.split(separator: ",") {
|
|
1178
|
+
let parts = raw.split(separator: ":", maxSplits: 2).map { String($0).trimmingSpaces() }
|
|
1179
|
+
var event: [String: _JSON] = [:]
|
|
1180
|
+
switch parts.first {
|
|
1181
|
+
case "tap" where parts.count == 2:
|
|
1182
|
+
guard let id = Double(parts[1]) else { break }
|
|
1183
|
+
event = ["type": .string("tap"), "id": .number(id)]
|
|
1184
|
+
case "text" where parts.count == 3:
|
|
1185
|
+
guard let id = Double(parts[1]) else { break }
|
|
1186
|
+
event = ["type": .string("text"), "id": .number(id), "value": .string(parts[2])]
|
|
1187
|
+
case "toggle" where parts.count == 3:
|
|
1188
|
+
guard let id = Double(parts[1]) else { break }
|
|
1189
|
+
event = ["type": .string("toggle"), "id": .number(id), "value": .bool(parts[2] == "true")]
|
|
1190
|
+
case "env" where parts.count == 2:
|
|
1191
|
+
event = ["type": .string("environment"), "colorScheme": .string(parts[1])]
|
|
1192
|
+
case "type" where parts.count == 2:
|
|
1193
|
+
event = ["type": .string("environment"), "dynamicTypeSize": .string(parts[1])]
|
|
1194
|
+
case "size" where parts.count == 2:
|
|
1195
|
+
event = ["type": .string("environment"), "horizontalSizeClass": .string(parts[1])]
|
|
1196
|
+
case "select" where parts.count == 3:
|
|
1197
|
+
guard let id = Double(parts[1]), let index = Double(parts[2]) else { break }
|
|
1198
|
+
event = ["type": .string("select"), "id": .number(id), "value": .number(index)]
|
|
1199
|
+
case "step" where parts.count == 3, "slide" where parts.count == 3, "date" where parts.count == 3:
|
|
1200
|
+
guard let id = Double(parts[1]), let value = Double(parts[2]) else { break }
|
|
1201
|
+
event = ["type": .string(parts[0]), "id": .number(id), "value": .number(value)]
|
|
1202
|
+
case "geometry" where parts.count == 3:
|
|
1203
|
+
let dims = parts[2].split(separator: "x").compactMap { Double($0) }
|
|
1204
|
+
guard let id = Double(parts[1]), dims.count == 2 else { break }
|
|
1205
|
+
event = ["type": .string("geometry"), "id": .number(id), "width": .number(dims[0]), "height": .number(dims[1])]
|
|
1206
|
+
case "drag" where parts.count == 3:
|
|
1207
|
+
// drag:12:changed:40x-3 (phase and translation)
|
|
1208
|
+
let rest = parts[2].split(separator: ":").map { String($0).trimmingSpaces() }
|
|
1209
|
+
guard let id = Double(parts[1]), rest.count == 2 else { break }
|
|
1210
|
+
let dims = rest[1].split(separator: "x").compactMap { Double($0) }
|
|
1211
|
+
guard dims.count == 2 else { break }
|
|
1212
|
+
event = [
|
|
1213
|
+
"type": .string("drag"), "id": .number(id), "phase": .string(rest[0]),
|
|
1214
|
+
"translation": .object(["width": .number(dims[0]), "height": .number(dims[1])]),
|
|
1215
|
+
]
|
|
1216
|
+
case "longpress" where parts.count == 2, "delete" where parts.count == 2, "refresh" where parts.count == 2:
|
|
1217
|
+
guard let id = Double(parts[1]) else { break }
|
|
1218
|
+
event = ["type": .string(parts[0]), "id": .number(id)]
|
|
1219
|
+
default:
|
|
1220
|
+
break
|
|
1221
|
+
}
|
|
1222
|
+
guard !event.isEmpty else {
|
|
1223
|
+
print("{\"message\":\"unrecognized event \(raw)\",\"op\":\"error\"}")
|
|
1224
|
+
continue
|
|
1225
|
+
}
|
|
1226
|
+
host.apply([.event(.object(event))])
|
|
1227
|
+
dispatch(event: .object(event))
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private extension String {
|
|
1233
|
+
func trimmingSpaces() -> String {
|
|
1234
|
+
String(drop(while: { $0 == " " }).reversed().drop(while: { $0 == " " }).reversed())
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/// Views that run an action when first mounted.
|
|
1239
|
+
protocol _OnAppearing {
|
|
1240
|
+
var _action: (@MainActor () -> Void)? { get }
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
extension _OnAppearView: _OnAppearing {
|
|
1244
|
+
var _action: (@MainActor () -> Void)? { action }
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
func _primitiveJSON(_ value: Any) -> _JSON? {
|
|
1248
|
+
switch value {
|
|
1249
|
+
case let v as Int: .number(Double(v))
|
|
1250
|
+
case let v as Double: .number(v)
|
|
1251
|
+
case let v as String: .string(v)
|
|
1252
|
+
case let v as Bool: .bool(v)
|
|
1253
|
+
default: nil
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/// The WASI environment (`SB_*` variables the host sets); nil when unset.
|
|
1258
|
+
public func _environment(_ name: String) -> String? {
|
|
1259
|
+
guard let value = getenv(name) else { return nil }
|
|
1260
|
+
return String(cString: value)
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/// Identity for a sheet element at the root, so re-renders keep its id.
|
|
1264
|
+
struct _SheetKey: Hashable {
|
|
1265
|
+
let path: String
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/// Identity for an alert element at the root.
|
|
1269
|
+
struct _AlertKey: Hashable {
|
|
1270
|
+
let path: String
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/// The element with every `button` in its subtree made to also set
|
|
1274
|
+
/// `isPresented` to false when tapped (an alert's action buttons dismiss it).
|
|
1275
|
+
private func _dismissing(_ element: _Element, with isPresented: Binding<Bool>) -> _Element {
|
|
1276
|
+
var copy = element
|
|
1277
|
+
if element.kind == "button" {
|
|
1278
|
+
let action = element.handler
|
|
1279
|
+
copy.handler = { payload in
|
|
1280
|
+
action?(payload)
|
|
1281
|
+
isPresented.wrappedValue = false
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
copy.children = element.children.map { _dismissing($0, with: isPresented) }
|
|
1285
|
+
return copy
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// MARK: - Tab and picker helpers
|
|
1289
|
+
|
|
1290
|
+
/// Flattens structural groups (tuples, optionals, conditionals, ForEach) into
|
|
1291
|
+
/// the views they contribute, keeping ForEach ids as tags when asked.
|
|
1292
|
+
@MainActor
|
|
1293
|
+
func _flattenChildren(_ view: any View, keys: Bool) -> [(view: any View, tag: AnyHashable?)] {
|
|
1294
|
+
if let group = view as? any _GroupView,
|
|
1295
|
+
!(view is any _TabItemProviding), !(view is any _TagProviding) {
|
|
1296
|
+
let children = group._children
|
|
1297
|
+
let ids = keys ? group._keys : nil
|
|
1298
|
+
return children.enumerated().flatMap { index, child -> [(view: any View, tag: AnyHashable?)] in
|
|
1299
|
+
let nested = _flattenChildren(child, keys: keys)
|
|
1300
|
+
guard let ids else { return nested }
|
|
1301
|
+
return nested.map { ($0.view, $0.tag ?? ids[index]) }
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
return [(view, _tag(of: view))]
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
@MainActor
|
|
1308
|
+
func _flattenChildren(_ view: any View) -> [any View] {
|
|
1309
|
+
_flattenChildren(view, keys: false).map(\.view)
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/// The `.tag` applied to a view, looking through other wrappers.
|
|
1313
|
+
@MainActor
|
|
1314
|
+
func _tag(of view: any View) -> AnyHashable? {
|
|
1315
|
+
if let tagged = view as? any _TagProviding { return tagged._tag }
|
|
1316
|
+
if let item = view as? any _TabItemProviding { return _tag(of: item._tabContent) }
|
|
1317
|
+
if let group = view as? any _GroupView, group._children.count == 1, !(view is TupleView<Any>) {
|
|
1318
|
+
return _tag(of: group._children[0])
|
|
1319
|
+
}
|
|
1320
|
+
return nil
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/// The `.tabItem` label applied to a view, looking through other wrappers.
|
|
1324
|
+
@MainActor
|
|
1325
|
+
func _tabLabel(of view: any View) -> (any View)? {
|
|
1326
|
+
if let item = view as? any _TabItemProviding { return item._tabLabel }
|
|
1327
|
+
if let tagged = view as? any _TagProviding { return _tabLabel(of: tagged._tagged) }
|
|
1328
|
+
if let group = view as? any _GroupView, group._children.count == 1 {
|
|
1329
|
+
return _tabLabel(of: group._children[0])
|
|
1330
|
+
}
|
|
1331
|
+
return nil
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/// Removes `toolbar` and `searchfield` elements from anywhere under `element`
|
|
1335
|
+
/// (appending them to `bar`) and returns the element without them.
|
|
1336
|
+
private func _hoistBarItems(from element: _Element, into bar: inout [_Element]) -> _Element {
|
|
1337
|
+
var kept: [_Element] = []
|
|
1338
|
+
var changed = false
|
|
1339
|
+
for child in element.children {
|
|
1340
|
+
if child.kind == "toolbar" || child.kind == "searchfield" {
|
|
1341
|
+
bar.append(child)
|
|
1342
|
+
changed = true
|
|
1343
|
+
} else {
|
|
1344
|
+
let hoisted = _hoistBarItems(from: child, into: &bar)
|
|
1345
|
+
if hoisted.token != child.token { changed = true }
|
|
1346
|
+
kept.append(hoisted)
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
// Unchanged subtrees keep their element (and token), so the reconciler
|
|
1350
|
+
// can skip them; a modified copy is a new element.
|
|
1351
|
+
guard changed else { return element }
|
|
1352
|
+
var copy = element
|
|
1353
|
+
copy.children = kept
|
|
1354
|
+
copy.token = _Element.nextToken()
|
|
1355
|
+
return copy
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/// A picker's options, looking through `Section`s (their headers are dropped).
|
|
1359
|
+
@MainActor
|
|
1360
|
+
func _flattenPickerOptions(_ options: any View) -> [(view: any View, tag: AnyHashable?)] {
|
|
1361
|
+
_flattenChildren(options, keys: true).flatMap { entry -> [(view: any View, tag: AnyHashable?)] in
|
|
1362
|
+
if let section = entry.view as? any _SectionView {
|
|
1363
|
+
return _flattenPickerOptions(section._sectionContent)
|
|
1364
|
+
}
|
|
1365
|
+
return [entry]
|
|
1366
|
+
}
|
|
1367
|
+
}
|