@swiftbrowser/native 0.19.0 → 0.21.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 (64) hide show
  1. package/README.md +34 -11
  2. package/dist/blob.d.ts +85 -11
  3. package/dist/blob.d.ts.map +1 -1
  4. package/dist/blob.js +184 -34
  5. package/dist/blob.js.map +1 -1
  6. package/dist/bridge.d.ts +33 -2
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +19 -11
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +105 -41
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +3 -1
  14. package/dist/hostRequests.d.ts.map +1 -1
  15. package/dist/hostRequests.js +47 -6
  16. package/dist/hostRequests.js.map +1 -1
  17. package/dist/index.d.ts +1 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/runtime.js +1 -1
  22. package/dist/runtime.js.map +1 -1
  23. package/dist/webSocket.d.ts.map +1 -1
  24. package/dist/webSocket.js +13 -1
  25. package/dist/webSocket.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/blob.ts +215 -34
  28. package/src/bridge.ts +27 -2
  29. package/src/fetch.ts +99 -41
  30. package/src/hostRequests.ts +62 -8
  31. package/src/index.ts +1 -2
  32. package/src/runtime.ts +1 -1
  33. package/src/webSocket.ts +10 -1
  34. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +122 -19
  35. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
  36. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1247 -209
  37. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +314 -19
  38. package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
  39. package/swift/Packages/SwiftBrowserShader/README.md +45 -0
  40. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
  41. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
  42. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
  43. package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
  44. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +141 -7
  45. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +47 -1
  46. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +98 -29
  47. package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
  48. package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
  49. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +11 -7
  50. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
  51. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +15 -10
  52. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +31 -28
  53. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +142 -0
  54. package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
  55. package/swift/Sources/SwiftBrowserProtocol/Props.swift +5 -2
  56. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
  57. package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
  58. package/wasm/SwiftBrowserNative.wasm +2 -2
  59. package/dist/native.d.ts +0 -19
  60. package/dist/native.d.ts.map +0 -1
  61. package/dist/native.js +0 -17
  62. package/dist/native.js.map +0 -1
  63. package/src/native.ts +0 -35
  64. package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +0 -53
@@ -59,6 +59,8 @@ extension View {
59
59
  }
60
60
  // `labelIcon` marks a `Label`'s icon; HStackElement draws the label as one.
61
61
  if let alignment = style.raw.string("gridColumnAlignment") { view = AnyView(view.gridColumnAlignment(HorizontalAlignment(name: alignment))) }
62
+ if let columns = style.gridCellColumns { view = AnyView(view.gridCellColumns(columns)) }
63
+ if let anchor = style.gridCellAnchor { view = AnyView(view.gridCellAnchor(UnitPoint(spec: anchor))) }
62
64
 
63
65
  // Layout.
64
66
  if style.fixedSizeHorizontal != nil || style.fixedSizeVertical != nil {
@@ -107,6 +109,7 @@ extension View {
107
109
  view = AnyView(view.aspectRatio(ratio.map { CGFloat($0) }, contentMode: mode))
108
110
  }
109
111
  if let position = style.position { view = AnyView(view.position(x: position.x, y: position.y)) }
112
+ if let guide = style.alignmentGuide { view = view.alignmentGuide(spec: guide) }
110
113
 
111
114
  // Paint.
112
115
  if let background = style.background {
@@ -119,6 +122,7 @@ extension View {
119
122
  if let radius = style.cornerRadius { view = AnyView(view.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))) }
120
123
  if style.clipped { view = AnyView(view.clipped()) }
121
124
  if let mask = style.mask { view = AnyView(view.mask { Rectangle().fill(AnyShapeStyle(spec: mask)) }) }
125
+ if style.compositingGroup { view = AnyView(view.compositingGroup()) }
122
126
  if let shadow = style.shadow {
123
127
  view = AnyView(view.shadow(color: shadow.color.map(Color.init(spec:)) ?? Color.black.opacity(0.33), radius: shadow.radius, x: shadow.x, y: shadow.y))
124
128
  }
@@ -137,6 +141,12 @@ extension View {
137
141
  if let rotation = style.rotation {
138
142
  view = AnyView(view.rotationEffect(.degrees(rotation), anchor: style.rotationAnchor.map(UnitPoint.init(spec:)) ?? .center))
139
143
  }
144
+ if let rotation = style.rotation3D {
145
+ view = AnyView(view.rotation3DEffect(
146
+ .degrees(rotation.angle), axis: (x: rotation.x, y: rotation.y, z: rotation.z),
147
+ anchor: rotation.anchor.map(UnitPoint.init(spec:)) ?? .center, perspective: rotation.perspective
148
+ ))
149
+ }
140
150
  if let scale = style.scale {
141
151
  view = AnyView(view.scaleEffect(x: scale.x, y: scale.y, anchor: style.scaleAnchor.map(UnitPoint.init(spec:)) ?? .center))
142
152
  }
@@ -150,6 +160,11 @@ extension View {
150
160
  if style.contentShape == "rectangle" { view = AnyView(view.contentShape(Rectangle())) }
151
161
  if style.hidden { view = AnyView(view.hidden()) }
152
162
  if let help = style.help { view = AnyView(view.help(help)) }
163
+ if let pointer = style.pointerStyle { view = view.pointerStyle(name: pointer) }
164
+ if let effect = style.symbolEffect { view = AnyView(view.modifier(SymbolEffectModifier(spec: effect))) }
165
+ // The events a box sends of its own.
166
+ if let id, style.hover { view = AnyView(view.modifier(HoverReport(id: id))) }
167
+ if let id, let threshold = style.scrollVisibilityThreshold { view = AnyView(view.modifier(ScrollVisibilityReport(id: id, threshold: threshold))) }
153
168
  // A `ScrollViewReader`'s `.id(x)`: the element's own id names it to a `scrollTo` and a scroll position.
154
169
  if style.scrollID, let id { view = AnyView(view.id(id)) }
155
170
  if let width = style.raw.double("inspectorColumnWidth") { view = AnyView(view.inspectorColumnWidth(width)) }
@@ -180,6 +195,133 @@ extension View {
180
195
  }
181
196
  }
182
197
 
198
+ extension View {
199
+ /// `.alignmentGuide`: the guide by name on its axis, at `a + b·width + c·height` of the view.
200
+ func alignmentGuide(spec: AlignmentGuideSpec) -> AnyView {
201
+ let (a, b, c) = (spec.a, spec.b, spec.c)
202
+ if spec.axis == "vertical" {
203
+ return AnyView(alignmentGuide(VerticalAlignment(name: spec.name)) { d in a + b * d.width + c * d.height })
204
+ }
205
+ return AnyView(alignmentGuide(HorizontalAlignment(name: spec.name)) { d in a + b * d.width + c * d.height })
206
+ }
207
+
208
+ /// `.pointerStyle`: the Mac's pointer by its CSS name (macOS 15). iOS has no pointer styles of
209
+ /// this kind (an iPad's pointer takes the shape of what it hovers), so the view is as it was.
210
+ func pointerStyle(name: String) -> AnyView {
211
+ #if os(macOS) && !canImport(FoundationBridge)
212
+ if #available(macOS 15, *) {
213
+ let style: PointerStyle = switch name {
214
+ case "pointer", "link": .link
215
+ case "text": .horizontalText
216
+ case "vertical-text", "verticalText": .verticalText
217
+ case "grab", "grabIdle": .grabIdle
218
+ case "grabbing", "grabActive": .grabActive
219
+ case "zoom-in", "zoomIn": .zoomIn
220
+ case "zoom-out", "zoomOut": .zoomOut
221
+ case "crosshair", "rectSelection": .rectSelection
222
+ default: .default
223
+ }
224
+ return AnyView(self.pointerStyle(style))
225
+ }
226
+ #endif
227
+ return AnyView(self)
228
+ }
229
+ }
230
+
231
+ /// `.onHover`: the box's `hover` events, as a pointer (an iPad's, a Mac's) enters and leaves it.
232
+ struct HoverReport: ViewModifier {
233
+ let id: Int
234
+ @Environment(\.nativeApp) private var app
235
+
236
+ func body(content: Content) -> some View {
237
+ content.onHover { hovering in app?.dispatch(.hover(id, hovering)) }
238
+ }
239
+ }
240
+
241
+ /// `.onScrollVisibilityChange(threshold:)` (iOS 18, macOS 15): the box's `scrollvisibility` as it
242
+ /// crosses the threshold of its scroll view's visible area; earlier systems send none.
243
+ struct ScrollVisibilityReport: ViewModifier {
244
+ let id: Int
245
+ let threshold: Double
246
+ @Environment(\.nativeApp) private var app
247
+
248
+ func body(content: Content) -> some View {
249
+ if #available(iOS 18, macOS 15, *) {
250
+ content.onScrollVisibilityChange(threshold: threshold) { visible in app?.dispatch(.scrollVisibility(id, visible: visible)) }
251
+ } else {
252
+ content
253
+ }
254
+ }
255
+ }
256
+
257
+ /// `.symbolEffect`: SF Symbols' own effect, on the symbols inside. With a `trigger` it plays once each
258
+ /// time the trigger changes; without, it runs while the style has it. `rotate`, `wiggle` and `breathe`,
259
+ /// and a `bounce` that runs on, need iOS 18 / macOS 15; before, the symbol is still.
260
+ struct SymbolEffectModifier: ViewModifier {
261
+ let spec: SymbolEffectSpec
262
+
263
+ private var options: SymbolEffectOptions {
264
+ var options: SymbolEffectOptions = .default
265
+ if let speed = spec.speed { options = options.speed(speed) }
266
+ if let count = spec.repeatCount { options = options.repeat(count) } else if spec.forever { options = options.repeating }
267
+ return options
268
+ }
269
+
270
+ private var up: Bool { spec.direction != "down" }
271
+
272
+ func body(content: Content) -> some View {
273
+ if let trigger = spec.trigger {
274
+ discrete(content, trigger: trigger)
275
+ } else {
276
+ running(content)
277
+ }
278
+ }
279
+
280
+ @ViewBuilder
281
+ private func discrete(_ content: Content, trigger: Double) -> some View {
282
+ switch spec.effect {
283
+ case "bounce": content.symbolEffect(up ? .bounce.up : .bounce.down, options: options, value: trigger)
284
+ case "pulse": content.symbolEffect(.pulse, options: options, value: trigger)
285
+ case "variableColor": content.symbolEffect(.variableColor, options: options, value: trigger)
286
+ case "rotate", "wiggle", "breathe":
287
+ if #available(iOS 18, macOS 15, *) {
288
+ switch spec.effect {
289
+ case "rotate": content.symbolEffect(.rotate, options: options, value: trigger)
290
+ case "wiggle": content.symbolEffect(.wiggle, options: options, value: trigger)
291
+ default: content.symbolEffect(.breathe, options: options, value: trigger)
292
+ }
293
+ } else {
294
+ content
295
+ }
296
+ // Scale, appear and disappear only run on: while the style has them.
297
+ default: running(content)
298
+ }
299
+ }
300
+
301
+ @ViewBuilder
302
+ private func running(_ content: Content) -> some View {
303
+ switch spec.effect {
304
+ case "pulse": content.symbolEffect(.pulse, options: options, isActive: true)
305
+ case "variableColor": content.symbolEffect(.variableColor, options: options, isActive: true)
306
+ case "scale": content.symbolEffect(up ? .scale.up : .scale.down, options: options, isActive: true)
307
+ case "appear": content.symbolEffect(.appear, options: options, isActive: true)
308
+ case "disappear": content.symbolEffect(.disappear, options: options, isActive: true)
309
+ case "bounce", "rotate", "wiggle", "breathe":
310
+ if #available(iOS 18, macOS 15, *) {
311
+ switch spec.effect {
312
+ case "bounce": content.symbolEffect(up ? .bounce.up : .bounce.down, options: options, isActive: true)
313
+ case "rotate": content.symbolEffect(.rotate, options: options, isActive: true)
314
+ case "wiggle": content.symbolEffect(.wiggle, options: options, isActive: true)
315
+ default: content.symbolEffect(.breathe, options: options, isActive: true)
316
+ }
317
+ } else {
318
+ content
319
+ }
320
+ default: content
321
+ }
322
+ }
323
+ }
324
+
183
325
  extension View {
184
326
  /// `.scrollTransition`: SwiftUI's own, its closure answering each phase
185
327
  /// with the app's effects for it (SwiftUI draws between them as the view
@@ -0,0 +1,306 @@
1
+ import Foundation
2
+
3
+ // The props and event values of the kinds that draw or lay out on the app's
4
+ // say (docs/ops-protocol.md): a custom `layout`, a `canvas`'s commands, and
5
+ // the drag and drop pair.
6
+
7
+ // MARK: - layout (a custom `Layout`)
8
+
9
+ /// One axis of a proposal: points, unbounded (`"infinity"`), or unspecified (nil).
10
+ public enum LayoutDimensionSpec: Equatable, Sendable {
11
+ case points(Double)
12
+ case infinity
13
+
14
+ public init?(json: JSONValue) {
15
+ if let value = json.double { self = .points(value) } else if json.string == "infinity" { self = .infinity } else { return nil }
16
+ }
17
+
18
+ /// From a length: unbounded when not finite, else to two decimals as the protocol carries it.
19
+ public init(_ value: Double) {
20
+ self = value.isFinite ? .points((value * 100).rounded() / 100) : .infinity
21
+ }
22
+
23
+ public var value: Double {
24
+ switch self {
25
+ case .points(let points): points
26
+ case .infinity: .infinity
27
+ }
28
+ }
29
+
30
+ public var json: JSONValue {
31
+ switch self {
32
+ case .points(let points): .number(points)
33
+ case .infinity: .string("infinity")
34
+ }
35
+ }
36
+ }
37
+
38
+ /// A proposal as Swift's `ProposedViewSize`: nil for an unspecified axis.
39
+ public struct LayoutProposalSpec: Equatable, Sendable {
40
+ public var width: LayoutDimensionSpec?
41
+ public var height: LayoutDimensionSpec?
42
+
43
+ public init(width: LayoutDimensionSpec?, height: LayoutDimensionSpec?) {
44
+ self.width = width
45
+ self.height = height
46
+ }
47
+
48
+ public init?(json: JSONValue) {
49
+ guard let fields = json.object else { return nil }
50
+ width = LayoutDimensionSpec(json: fields.value("width"))
51
+ height = LayoutDimensionSpec(json: fields.value("height"))
52
+ }
53
+
54
+ public var json: JSONValue {
55
+ .object(["width": width?.json ?? .null, "height": height?.json ?? .null])
56
+ }
57
+ }
58
+
59
+ /// A size whose axes may be unbounded.
60
+ public struct LayoutSizeSpec: Equatable, Sendable {
61
+ public var width: LayoutDimensionSpec
62
+ public var height: LayoutDimensionSpec
63
+
64
+ public init(width: Double, height: Double) {
65
+ self.width = LayoutDimensionSpec(width)
66
+ self.height = LayoutDimensionSpec(height)
67
+ }
68
+
69
+ public init?(json: JSONValue) {
70
+ guard let width = LayoutDimensionSpec(json: json["width"]), let height = LayoutDimensionSpec(json: json["height"]) else { return nil }
71
+ self.width = width
72
+ self.height = height
73
+ }
74
+
75
+ public var json: JSONValue { .object(["width": width.json, "height": height.json]) }
76
+ }
77
+
78
+ /// A subview's sizes for a zero, unspecified and unbounded proposal: what the app's layout measures it by.
79
+ public struct LayoutMeasurementSpec: Equatable, Sendable {
80
+ public var min: LayoutSizeSpec
81
+ public var ideal: LayoutSizeSpec
82
+ public var max: LayoutSizeSpec
83
+
84
+ public init(min: LayoutSizeSpec, ideal: LayoutSizeSpec, max: LayoutSizeSpec) {
85
+ self.min = min
86
+ self.ideal = ideal
87
+ self.max = max
88
+ }
89
+
90
+ public var json: JSONValue { .object(["min": min.json, "ideal": ideal.json, "max": max.json]) }
91
+ }
92
+
93
+ /// `layout`: the app's answer for `proposal` (nil until its first): its size, and one frame per child
94
+ /// relative to its origin; `sizes`, its own size for a zero, unspecified and unbounded proposal.
95
+ public struct LayoutProps: Equatable, Sendable {
96
+ public struct Frame: Equatable, Sendable {
97
+ public var x: Double
98
+ public var y: Double
99
+ public var width: Double
100
+ public var height: Double
101
+ }
102
+
103
+ public var proposal: LayoutProposalSpec?
104
+ public var size: SizeSpec?
105
+ public var frames: [Frame]?
106
+ public var min: LayoutSizeSpec?
107
+ public var ideal: LayoutSizeSpec?
108
+ public var max: LayoutSizeSpec?
109
+
110
+ public init(props: JSONObject) {
111
+ proposal = LayoutProposalSpec(json: props.value("proposal"))
112
+ size = SizeSpec(json: props.value("size"))
113
+ frames = props.array("frames")?.compactMap { json -> Frame? in
114
+ guard let fields = json.object else { return nil }
115
+ return Frame(x: fields.double("x") ?? 0, y: fields.double("y") ?? 0, width: fields.double("width") ?? 0, height: fields.double("height") ?? 0)
116
+ }
117
+ let sizes = props.object("sizes")
118
+ min = sizes.flatMap { LayoutSizeSpec(json: $0.value("min")) }
119
+ ideal = sizes.flatMap { LayoutSizeSpec(json: $0.value("ideal")) }
120
+ max = sizes.flatMap { LayoutSizeSpec(json: $0.value("max")) }
121
+ }
122
+ }
123
+
124
+ // MARK: - canvas
125
+
126
+ /// What a `canvas` command paints with: a view's shape style, or a gradient in the drawing's own points.
127
+ public enum CanvasShadingSpec: Equatable, Sendable {
128
+ case style(ShapeStyleSpec)
129
+ case linearGradient(stops: [GradientStopSpec], start: PointSpec, end: PointSpec)
130
+ case radialGradient(stops: [GradientStopSpec], center: PointSpec, startRadius: Double, endRadius: Double)
131
+
132
+ public init?(json: JSONValue) {
133
+ if let gradient = json["canvasLinearGradient"].object,
134
+ let start = PointSpec(json: gradient.value("start")), let end = PointSpec(json: gradient.value("end"))
135
+ {
136
+ self = .linearGradient(stops: (gradient.array("stops") ?? []).compactMap(GradientStopSpec.init(json:)), start: start, end: end)
137
+ } else if let gradient = json["canvasRadialGradient"].object, let center = PointSpec(json: gradient.value("center")) {
138
+ self = .radialGradient(
139
+ stops: (gradient.array("stops") ?? []).compactMap(GradientStopSpec.init(json:)), center: center,
140
+ startRadius: gradient.double("startRadius") ?? 0, endRadius: gradient.double("endRadius") ?? 0
141
+ )
142
+ } else if let style = ShapeStyleSpec(json: json) {
143
+ self = .style(style)
144
+ } else {
145
+ return nil
146
+ }
147
+ }
148
+ }
149
+
150
+ /// A `GraphicsContext.Filter`.
151
+ public enum CanvasFilterSpec: Equatable, Sendable {
152
+ case blur(Double)
153
+ case shadow(color: ColorSpec?, radius: Double, x: Double, y: Double)
154
+ case grayscale(Double)
155
+ case saturation(Double)
156
+ case brightness(Double)
157
+ case contrast(Double)
158
+ case hueRotation(Double)
159
+ case invert(Double)
160
+
161
+ public init?(json: JSONValue) {
162
+ guard let fields = json.object else { return nil }
163
+ if let radius = fields.double("blur") {
164
+ self = .blur(radius)
165
+ } else if let shadow = fields.object("shadow") {
166
+ self = .shadow(color: ColorSpec(json: shadow.value("color")), radius: shadow.double("radius") ?? 0, x: shadow.double("x") ?? 0, y: shadow.double("y") ?? 0)
167
+ } else if let amount = fields.double("grayscale") {
168
+ self = .grayscale(amount)
169
+ } else if let amount = fields.double("saturation") {
170
+ self = .saturation(amount)
171
+ } else if let amount = fields.double("brightness") {
172
+ self = .brightness(amount)
173
+ } else if let amount = fields.double("contrast") {
174
+ self = .contrast(amount)
175
+ } else if let degrees = fields.double("hueRotation") {
176
+ self = .hueRotation(degrees)
177
+ } else if let amount = fields.double("invert") {
178
+ self = .invert(amount)
179
+ } else {
180
+ return nil
181
+ }
182
+ }
183
+ }
184
+
185
+ /// One thing a `canvas` drew, with the context's state at the time: paths are SVG path data in the drawing's
186
+ /// coordinates (before `transform`), clips in the canvas's own.
187
+ public struct CanvasCommand: Equatable, Sendable {
188
+ public enum Operation: Equatable, Sendable {
189
+ case fill(path: String, shading: CanvasShadingSpec, evenOdd: Bool)
190
+ case stroke(path: String, shading: CanvasShadingSpec, style: StrokeStyleSpec)
191
+ case image(ImageProps, x: Double, y: Double, anchor: UnitPointSpec, width: Double?, height: Double?, shading: CanvasShadingSpec?)
192
+ case text(String, x: Double, y: Double, anchor: UnitPointSpec, shading: CanvasShadingSpec, font: FontSpec?)
193
+ }
194
+
195
+ /// `lineWidth`, the caps and joins by name, the dash pattern.
196
+ public struct StrokeStyleSpec: Equatable, Sendable {
197
+ public var lineWidth: Double
198
+ public var lineCap: String?
199
+ public var lineJoin: String?
200
+ public var miterLimit: Double?
201
+ public var dash: [Double]
202
+ public var dashPhase: Double
203
+ }
204
+
205
+ public var operation: Operation
206
+ /// `[a, b, c, d, tx, ty]`; nil for the identity.
207
+ public var transform: [Double]?
208
+ public var opacity: Double
209
+ public var blendMode: String?
210
+ public var clips: [String]
211
+ public var filters: [CanvasFilterSpec]
212
+
213
+ public init?(json: JSONValue) {
214
+ guard let fields = json.object, let op = fields.string("op") else { return nil }
215
+ let anchor = UnitPointSpec(json: fields.value("anchor")) ?? UnitPointSpec(x: 0.5, y: 0.5)
216
+ switch op {
217
+ case "fill":
218
+ guard let path = fields.string("path"), let shading = CanvasShadingSpec(json: fields.value("shading")) else { return nil }
219
+ operation = .fill(path: path, shading: shading, evenOdd: fields.bool("evenOdd") ?? false)
220
+ case "stroke":
221
+ guard let path = fields.string("path"), let shading = CanvasShadingSpec(json: fields.value("shading")) else { return nil }
222
+ operation = .stroke(path: path, shading: shading, style: StrokeStyleSpec(
223
+ lineWidth: fields.double("lineWidth") ?? 1, lineCap: fields.string("lineCap"), lineJoin: fields.string("lineJoin"),
224
+ miterLimit: fields.double("miterLimit"), dash: (fields.array("dash") ?? []).compactMap(\.double), dashPhase: fields.double("dashPhase") ?? 0
225
+ ))
226
+ case "image":
227
+ operation = .image(
228
+ ImageProps(props: fields.object("image") ?? [:]), x: fields.double("x") ?? 0, y: fields.double("y") ?? 0, anchor: anchor,
229
+ width: fields.double("width"), height: fields.double("height"), shading: CanvasShadingSpec(json: fields.value("shading"))
230
+ )
231
+ case "text":
232
+ guard let text = fields.string("text") else { return nil }
233
+ operation = .text(
234
+ text, x: fields.double("x") ?? 0, y: fields.double("y") ?? 0, anchor: anchor,
235
+ shading: CanvasShadingSpec(json: fields.value("shading")) ?? .style(.hierarchical(level: 1, opacity: 1)), font: FontSpec(json: fields.value("font"))
236
+ )
237
+ default:
238
+ return nil
239
+ }
240
+ let matrix = (fields.array("transform") ?? []).compactMap(\.double)
241
+ transform = matrix.count == 6 ? matrix : nil
242
+ opacity = fields.double("opacity") ?? 1
243
+ blendMode = fields.string("blendMode")
244
+ clips = (fields.array("clips") ?? []).compactMap(\.string)
245
+ filters = (fields.array("filters") ?? []).compactMap(CanvasFilterSpec.init(json:))
246
+ }
247
+ }
248
+
249
+ /// `canvas`: the commands the app's renderer drew for `width` × `height`, the size it was told.
250
+ public struct CanvasProps: Equatable, Sendable {
251
+ public var width: Double
252
+ public var height: Double
253
+ public var commands: [CanvasCommand]
254
+
255
+ public init(props: JSONObject) {
256
+ width = props.double("width") ?? 0
257
+ height = props.double("height") ?? 0
258
+ commands = (props.array("commands") ?? []).compactMap(CanvasCommand.init(json:))
259
+ }
260
+ }
261
+
262
+ // MARK: - drag and drop
263
+
264
+ /// `dropdestination`: the MIME patterns (`text/plain`, `image/*`, `*/*`) it takes from outside the app.
265
+ public struct DropDestinationProps: Equatable, Sendable {
266
+ public var accepts: [String]
267
+
268
+ public init(props: JSONObject) {
269
+ accepts = (props.array("accepts") ?? []).compactMap(\.string)
270
+ }
271
+
272
+ /// Whether text dragged in from outside the app is taken.
273
+ public var acceptsText: Bool {
274
+ accepts.contains { $0 == "*/*" || $0.hasPrefix("text/") }
275
+ }
276
+
277
+ /// Whether files (or folders) dragged in from another app may be taken: any pattern beyond text.
278
+ public var acceptsFiles: Bool {
279
+ accepts.contains { $0 == "*/*" || !$0.hasPrefix("text/") }
280
+ }
281
+
282
+ /// Whether a file of the MIME type is taken, as the page's `acceptsType` decides (src/dragDrop.ts).
283
+ public func accepts(mimeType: String) -> Bool {
284
+ let mime = mimeType.split(separator: ";").first.map { $0.trimmingCharacters(in: .whitespaces).lowercased() } ?? ""
285
+ let type = mime.isEmpty ? "application/octet-stream" : mime
286
+ return accepts.contains { pattern in
287
+ let p = pattern.lowercased()
288
+ if p == "*/*" { return true }
289
+ if p.hasSuffix("/*") { return type.hasPrefix(String(p.dropLast())) }
290
+ return type == p
291
+ }
292
+ }
293
+ }
294
+
295
+ /// What a `draggable` carries natively: a marker and the element's id, as text, so a drop names its source.
296
+ public enum DragPayload {
297
+ private static let prefix = "\u{2063}swiftbrowser-drag:"
298
+
299
+ public static func text(for source: Int) -> String { prefix + String(source) }
300
+
301
+ /// The `draggable` a dropped text names, if it is one of these.
302
+ public static func source(of text: String) -> Int? {
303
+ guard text.hasPrefix(prefix) else { return nil }
304
+ return Int(text.dropFirst(prefix.count))
305
+ }
306
+ }
@@ -325,9 +325,12 @@ public struct NavSplitProps: Equatable, Sendable {
325
325
  public struct NavLinkProps: Equatable, Sendable {
326
326
  public var selected: Bool
327
327
  public var plain: Bool
328
+ /// A split view sidebar's link: its row's selection takes the tap.
329
+ public var selects: Bool
328
330
  public init(props: JSONObject) {
329
331
  selected = props.bool("selected") ?? false
330
332
  plain = props.string("style") == "plain"
333
+ selects = props.bool("selects") ?? false
331
334
  }
332
335
  }
333
336
 
@@ -576,8 +579,8 @@ public struct TapGestureProps: Equatable, Sendable {
576
579
  }
577
580
  }
578
581
 
579
- /// The producer-only `native` kind (docs/native.md, "The native escape hatch").
580
- public struct NativeProps: Equatable, Sendable {
582
+ /// The `registered` kind: a view registered under `name` (docs/native.md, "Registered views").
583
+ public struct RegisteredProps: Equatable, Sendable {
581
584
  public var name: String
582
585
  public var props: JSONValue
583
586
  public init(props: JSONObject) {
@@ -158,9 +158,9 @@ public struct RendererEvent: Equatable, Sendable {
158
158
  }
159
159
  /// A `menu` opened or closed (lazy menus build their items on `open`).
160
160
  public static func menu(_ id: Int, open: Bool) -> RendererEvent { with("menu", id: id, ["open": .bool(open)]) }
161
- /// The host's answer to a `native` element's own event (docs/native.md, the escape hatch).
162
- public static func native(_ id: Int, name: String, value: JSONValue) -> RendererEvent {
163
- with("native", id: id, ["name": .string(name), "value": value])
161
+ /// What a `registered` element's view sent (docs/native.md, `ViewRegistry`).
162
+ public static func registered(_ id: Int, name: String, value: JSONValue) -> RendererEvent {
163
+ with("registered", id: id, ["name": .string(name), "value": value])
164
164
  }
165
165
 
166
166
  /// The device appearance, text size or size class (sent once at launch and on change).
@@ -178,6 +178,27 @@ public struct RendererEvent: Equatable, Sendable {
178
178
  return RendererEvent(type: "environment", fields: fields)
179
179
  }
180
180
 
181
+ /// A styled box with `hover` was entered (`true`) or left by the pointer (`.onHover`).
182
+ public static func hover(_ id: Int, _ hovering: Bool) -> RendererEvent { with("hover", id: id, ["value": .bool(hovering)]) }
183
+ /// A styled box with `scrollVisibility` crossed its threshold of the scroll view's visible area.
184
+ public static func scrollVisibility(_ id: Int, visible: Bool) -> RendererEvent { with("scrollvisibility", id: id, ["visible": .bool(visible)]) }
185
+ /// A `draggable` was lifted: the app makes its payload now and keeps it until `dragend`.
186
+ public static func dragStart(_ id: Int) -> RendererEvent { with("dragstart", id: id) }
187
+ /// The lifted `draggable` was dropped.
188
+ public static func dragEnd(_ id: Int) -> RendererEvent { with("dragend", id: id) }
189
+ /// A drag over a `dropdestination`: `entered`, `exited` or `performed`, with the lifted `draggable`
190
+ /// (`source`) or what came from outside the app (`items`); the point in the destination's own box.
191
+ public static func drop(_ id: Int, phase: String, source: Int? = nil, items: [JSONValue]? = nil, x: Double = 0, y: Double = 0) -> RendererEvent {
192
+ var fields: JSONObject = ["phase": .string(phase), "x": .number(x), "y": .number(y)]
193
+ if let source { fields["source"] = .number(Double(source)) }
194
+ if let items { fields["items"] = .array(items) }
195
+ return with("drop", id: id, fields)
196
+ }
197
+ /// A `layout` element's record: the proposal it was placed with and each child's min, ideal and max sizes.
198
+ public static func layout(_ id: Int, proposal: LayoutProposalSpec, children: [LayoutMeasurementSpec]) -> RendererEvent {
199
+ with("layout", id: id, ["proposal": proposal.json, "children": .array(children.map(\.json))])
200
+ }
201
+
181
202
  /// Several events delivered at once; the app renders once for all of them.
182
203
  public static func batch(_ events: [RendererEvent]) -> RendererEvent {
183
204
  RendererEvent(type: "batch", fields: ["events": .array(events.map(\.json))])
@@ -38,6 +38,7 @@ public struct Style: Equatable, Sendable {
38
38
  public var scale: PointSpec?
39
39
  public var scaleAnchor: UnitPointSpec?
40
40
  public var shadow: ShadowSpec?
41
+ public var compositingGroup: Bool
41
42
  public var clipped: Bool
42
43
  public var tint: ColorSpec?
43
44
  public var position: PointSpec?
@@ -77,6 +78,22 @@ public struct Style: Equatable, Sendable {
77
78
  public var scrollTo: ScrollToSpec?
78
79
  /// `.scrollTransition`: the effects in its three phases.
79
80
  public var scrollTransition: ScrollTransitionSpec?
81
+ /// `.gridCellColumns(_:)`: the `grid` columns the cell spans.
82
+ public var gridCellColumns: Int?
83
+ /// `.gridCellAnchor(_:)`: the unit point the cell aligns by in its cell.
84
+ public var gridCellAnchor: UnitPointSpec?
85
+ /// `.onScrollVisibilityChange(threshold:)`: the share of the box that counts as visible; the box sends `scrollvisibility`.
86
+ public var scrollVisibilityThreshold: Double?
87
+ /// `.alignmentGuide(_:computeValue:)`, as `a + b·width + c·height`.
88
+ public var alignmentGuide: AlignmentGuideSpec?
89
+ /// `.rotation3DEffect(_:axis:anchor:perspective:)`.
90
+ public var rotation3D: Rotation3DSpec?
91
+ /// `.onHover`: the box sends `hover` as a pointer enters and leaves it.
92
+ public var hover: Bool
93
+ /// `.pointerStyle(_:)`: the pointer over the box, by its CSS name (`pointer`, `text`, `grab`…).
94
+ public var pointerStyle: String?
95
+ /// `.symbolEffect(_:options:isActive:)` / `.symbolEffect(_:options:value:)`.
96
+ public var symbolEffect: SymbolEffectSpec?
80
97
 
81
98
  public init(json: JSONValue) {
82
99
  let fields = json.object ?? [:]
@@ -115,6 +132,7 @@ public struct Style: Equatable, Sendable {
115
132
  scale = PointSpec(json: fields.value("scale"))
116
133
  scaleAnchor = UnitPointSpec(json: fields.value("scaleAnchor"))
117
134
  shadow = ShadowSpec(json: fields.value("shadow"))
135
+ compositingGroup = fields.bool("compositingGroup") ?? false
118
136
  clipped = fields.bool("clipped") ?? false
119
137
  tint = ColorSpec(json: fields.value("tint"))
120
138
  position = PointSpec(json: fields.value("position"))
@@ -152,6 +170,14 @@ public struct Style: Equatable, Sendable {
152
170
  scrollID = fields.bool("scrollID") ?? false
153
171
  scrollTo = ScrollToSpec(json: fields.value("scrollTo"))
154
172
  scrollTransition = ScrollTransitionSpec(json: fields.value("scrollTransition"))
173
+ gridCellColumns = fields.int("gridCellColumns")
174
+ gridCellAnchor = UnitPointSpec(json: fields.value("gridCellAnchor"))
175
+ scrollVisibilityThreshold = fields.object("scrollVisibility")?.double("threshold")
176
+ alignmentGuide = AlignmentGuideSpec(json: fields.value("alignmentGuide"))
177
+ rotation3D = Rotation3DSpec(json: fields.value("rotation3D"))
178
+ hover = fields.bool("hover") ?? false
179
+ pointerStyle = fields.string("pointerStyle")
180
+ symbolEffect = SymbolEffectSpec(json: fields.value("symbolEffect"))
155
181
  }
156
182
 
157
183
  public init() {
@@ -162,6 +188,67 @@ public struct Style: Equatable, Sendable {
162
188
  public var fieldNames: [String] { raw.keys.sorted() }
163
189
  }
164
190
 
191
+ /// `.alignmentGuide(_:computeValue:)`: the box's guide `name` on `axis` is `a + b·width + c·height`.
192
+ public struct AlignmentGuideSpec: Equatable, Sendable {
193
+ /// `horizontal` (a `HorizontalAlignment`: leading, center, trailing…) or `vertical`.
194
+ public var axis: String
195
+ public var name: String
196
+ public var a: Double
197
+ public var b: Double
198
+ public var c: Double
199
+ public init?(json: JSONValue) {
200
+ guard let fields = json.object, let axis = fields.string("axis"), let name = fields.string("name") else { return nil }
201
+ let value = (fields.array("value") ?? []).compactMap(\.double)
202
+ self.axis = axis
203
+ self.name = name
204
+ a = value.count > 0 ? value[0] : 0
205
+ b = value.count > 1 ? value[1] : 0
206
+ c = value.count > 2 ? value[2] : 0
207
+ }
208
+ }
209
+
210
+ /// `.rotation3DEffect`: degrees about `axis`, around `anchor`, with SwiftUI's perspective factor.
211
+ public struct Rotation3DSpec: Equatable, Sendable {
212
+ public var angle: Double
213
+ public var x: Double
214
+ public var y: Double
215
+ public var z: Double
216
+ public var anchor: UnitPointSpec?
217
+ public var perspective: Double
218
+ public init?(json: JSONValue) {
219
+ guard let fields = json.object, let angle = fields.double("angle") else { return nil }
220
+ self.angle = angle
221
+ let axis = fields.object("axis") ?? [:]
222
+ x = axis.double("x") ?? 0
223
+ y = axis.double("y") ?? 0
224
+ z = axis.double("z") ?? 0
225
+ anchor = UnitPointSpec(json: fields.value("anchor"))
226
+ perspective = fields.double("perspective") ?? 1
227
+ }
228
+ }
229
+
230
+ /// An SF Symbols effect: `effect` by name (`bounce`, `pulse`, `variableColor`, `scale`, `rotate`, `wiggle`,
231
+ /// `breathe`, `appear`, `disappear`), run while the style has it, or once each time `trigger` changes.
232
+ public struct SymbolEffectSpec: Equatable, Sendable {
233
+ public var effect: String
234
+ /// `up` or `down`.
235
+ public var direction: String?
236
+ public var speed: Double?
237
+ /// How many times a run repeats; nil with `forever` false for the effect's own.
238
+ public var repeatCount: Int?
239
+ public var forever: Bool
240
+ public var trigger: Double?
241
+ public init?(json: JSONValue) {
242
+ guard let fields = json.object, let effect = fields.string("effect") else { return nil }
243
+ self.effect = effect
244
+ direction = fields.string("direction")
245
+ speed = fields.double("speed")
246
+ repeatCount = fields.int("repeat")
247
+ forever = fields.string("repeat") == "forever"
248
+ trigger = fields.double("trigger")
249
+ }
250
+ }
251
+
165
252
  /// `.containerRelativeFrame(axes)` as the style hint carries it: `length = a × container + b`.
166
253
  public struct ContainerRelativeFrameSpec: Equatable, Sendable {
167
254
  public var horizontal: Bool