@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.
Files changed (74) hide show
  1. package/Package.swift +23 -0
  2. package/README.md +12 -2
  3. package/Sources/Charts/Charts.swift +65 -0
  4. package/Sources/FoundationBridge/FoundationBridge.swift +77 -0
  5. package/Sources/FoundationBridge/Locale.swift +86 -0
  6. package/Sources/FoundationBridge/Timer.swift +48 -0
  7. package/Sources/FoundationBridge/UITextChecker.swift +30 -0
  8. package/Sources/FoundationLite/Common.swift +3 -0
  9. package/Sources/FoundationLite/FoundationLite.swift +171 -0
  10. package/Sources/FoundationLite/Interaction.swift +146 -0
  11. package/Sources/FoundationLite/Timer.swift +48 -0
  12. package/Sources/SwiftUI/API/Accessibility.swift +132 -0
  13. package/Sources/SwiftUI/API/Alert.swift +221 -0
  14. package/Sources/SwiftUI/API/Animation.swift +253 -0
  15. package/Sources/SwiftUI/API/App.swift +57 -0
  16. package/Sources/SwiftUI/API/Color.swift +161 -0
  17. package/Sources/SwiftUI/API/Controls.swift +268 -0
  18. package/Sources/SwiftUI/API/CustomLayout.swift +403 -0
  19. package/Sources/SwiftUI/API/Decorations.swift +92 -0
  20. package/Sources/SwiftUI/API/EditMode.swift +269 -0
  21. package/Sources/SwiftUI/API/Effects.swift +290 -0
  22. package/Sources/SwiftUI/API/Environment.swift +355 -0
  23. package/Sources/SwiftUI/API/Focus.swift +129 -0
  24. package/Sources/SwiftUI/API/Font.swift +131 -0
  25. package/Sources/SwiftUI/API/Gauge.swift +289 -0
  26. package/Sources/SwiftUI/API/Geometry.swift +89 -0
  27. package/Sources/SwiftUI/API/GeometryReader.swift +124 -0
  28. package/Sources/SwiftUI/API/Gestures.swift +342 -0
  29. package/Sources/SwiftUI/API/Grid.swift +188 -0
  30. package/Sources/SwiftUI/API/LabelStyle.swift +117 -0
  31. package/Sources/SwiftUI/API/Layout.swift +165 -0
  32. package/Sources/SwiftUI/API/Lists.swift +276 -0
  33. package/Sources/SwiftUI/API/Menu.swift +51 -0
  34. package/Sources/SwiftUI/API/Modifiers.swift +243 -0
  35. package/Sources/SwiftUI/API/Navigation.swift +371 -0
  36. package/Sources/SwiftUI/API/ObservableObject.swift +279 -0
  37. package/Sources/SwiftUI/API/OnChange.swift +48 -0
  38. package/Sources/SwiftUI/API/Phase8Controls.swift +394 -0
  39. package/Sources/SwiftUI/API/Picker.swift +176 -0
  40. package/Sources/SwiftUI/API/Presentation.swift +109 -0
  41. package/Sources/SwiftUI/API/Primitives.swift +390 -0
  42. package/Sources/SwiftUI/API/Publishers.swift +107 -0
  43. package/Sources/SwiftUI/API/RowActions.swift +83 -0
  44. package/Sources/SwiftUI/API/ScrollTargets.swift +104 -0
  45. package/Sources/SwiftUI/API/ShapeStyle.swift +311 -0
  46. package/Sources/SwiftUI/API/State.swift +127 -0
  47. package/Sources/SwiftUI/API/StrokeStyle.swift +30 -0
  48. package/Sources/SwiftUI/API/Tabs.swift +119 -0
  49. package/Sources/SwiftUI/API/Tasks.swift +35 -0
  50. package/Sources/SwiftUI/API/Timeline.swift +212 -0
  51. package/Sources/SwiftUI/API/Toolbar.swift +215 -0
  52. package/Sources/SwiftUI/API/View.swift +207 -0
  53. package/Sources/SwiftUI/API/ViewModifier.swift +40 -0
  54. package/Sources/SwiftUI/API/VisualEffect.swift +82 -0
  55. package/Sources/SwiftUI/Charts/Axis.swift +319 -0
  56. package/Sources/SwiftUI/Charts/Chart.swift +646 -0
  57. package/Sources/SwiftUI/Charts/ChartContent.swift +376 -0
  58. package/Sources/SwiftUI/Charts/Marks.swift +204 -0
  59. package/Sources/SwiftUI/Charts/Plottable.swift +99 -0
  60. package/Sources/SwiftUI/Charts/Scales.swift +326 -0
  61. package/Sources/SwiftUI/Runtime/Executor.swift +83 -0
  62. package/Sources/SwiftUI/Runtime/Exports.swift +65 -0
  63. package/Sources/SwiftUI/Runtime/JSON.swift +71 -0
  64. package/Sources/SwiftUI/Runtime/JSONParser.swift +443 -0
  65. package/Sources/SwiftUI/Runtime/Ops.swift +106 -0
  66. package/Sources/SwiftUI/Runtime/Reconciler.swift +210 -0
  67. package/Sources/SwiftUI/Runtime/Runtime.swift +1367 -0
  68. package/Sources/_FoundationCommon/Bundle.swift +82 -0
  69. package/Sources/_FoundationCommon/UserDefaults.swift +170 -0
  70. package/Sources/_FoundationCommon/_FoundationCommon.swift +10 -0
  71. package/index.d.ts +2 -0
  72. package/index.mjs +16 -0
  73. package/package.json +28 -4
  74. 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
+ }