@swiftbrowser/native 0.17.8 → 0.18.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 (60) hide show
  1. package/README.md +20 -9
  2. package/dist/bridge.d.ts +27 -1
  3. package/dist/bridge.d.ts.map +1 -1
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/hostRequests.d.ts +10 -4
  6. package/dist/hostRequests.d.ts.map +1 -1
  7. package/dist/hostRequests.js +65 -3
  8. package/dist/hostRequests.js.map +1 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/run.d.ts.map +1 -1
  14. package/dist/run.js +14 -1
  15. package/dist/run.js.map +1 -1
  16. package/dist/runtime.d.ts +1 -0
  17. package/dist/runtime.d.ts.map +1 -1
  18. package/dist/runtime.js +12 -1
  19. package/dist/runtime.js.map +1 -1
  20. package/dist/site.d.ts +3 -3
  21. package/dist/site.d.ts.map +1 -1
  22. package/dist/site.js +1 -1
  23. package/dist/site.js.map +1 -1
  24. package/dist/undo.d.ts +23 -0
  25. package/dist/undo.d.ts.map +1 -0
  26. package/dist/undo.js +44 -0
  27. package/dist/undo.js.map +1 -0
  28. package/package.json +3 -3
  29. package/src/bridge.ts +26 -1
  30. package/src/hostRequests.ts +72 -4
  31. package/src/index.ts +1 -0
  32. package/src/run.ts +11 -1
  33. package/src/runtime.ts +12 -1
  34. package/src/site.ts +3 -3
  35. package/src/undo.ts +47 -0
  36. package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
  37. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
  38. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +45 -0
  39. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +16 -2
  40. package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
  41. package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
  42. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +30 -0
  43. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +347 -12
  44. package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
  45. package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
  46. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
  47. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
  48. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
  49. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
  50. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
  51. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
  52. package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
  53. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
  54. package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
  55. package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
  56. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
  57. package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
  58. package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
  59. package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
  60. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -51,6 +51,8 @@ extension View {
51
51
  view = AnyView(view.imageScale(scale == "small" ? .small : scale == "large" ? .large : .medium))
52
52
  }
53
53
  if let tint = style.tint { view = AnyView(view.tint(Color(spec: tint))) }
54
+ // `.scrollDisabled`: the scroll views and text editors below (a text editor then as tall as its lines).
55
+ if let disabled = style.raw.bool("scrollDisabled") { view = AnyView(view.scrollDisabled(disabled)) }
54
56
  // `labelIcon` marks a `Label`'s icon; HStackElement draws the label as one.
55
57
  if let alignment = style.raw.string("gridColumnAlignment") { view = AnyView(view.gridColumnAlignment(HorizontalAlignment(name: alignment))) }
56
58
 
@@ -135,11 +137,13 @@ extension View {
135
137
  view = AnyView(view.scaleEffect(x: scale.x, y: scale.y, anchor: style.scaleAnchor.map(UnitPoint.init(spec:)) ?? .center))
136
138
  }
137
139
  if let offset = style.offset { view = AnyView(view.offset(x: offset.x, y: offset.y)) }
140
+ if let transition = style.scrollTransition { view = AnyView(view.scrollTransition(spec: transition)) }
138
141
  if let zIndex = style.zIndex { view = AnyView(view.zIndex(zIndex)) }
139
142
 
140
143
  // Interaction and visibility.
141
144
  if style.disabled == true { view = AnyView(view.disabled(true)) }
142
145
  if style.hitTesting == false { view = AnyView(view.allowsHitTesting(false)) }
146
+ if style.contentShape == "rectangle" { view = AnyView(view.contentShape(Rectangle())) }
143
147
  if style.hidden { view = AnyView(view.hidden()) }
144
148
  if let help = style.help { view = AnyView(view.help(help)) }
145
149
  // A `ScrollViewReader`'s `.id(x)`: the element's own id names it to a `scrollTo` and a scroll position.
@@ -172,6 +176,43 @@ extension View {
172
176
  }
173
177
  }
174
178
 
179
+ extension View {
180
+ /// `.scrollTransition`: SwiftUI's own, its closure answering each phase
181
+ /// with the app's effects for it (SwiftUI draws between them as the view
182
+ /// scrolls, for `interactive`). Every effect is applied in every phase, so
183
+ /// the closure's type is one and the phases interpolate field by field.
184
+ func scrollTransition(spec: ScrollTransitionSpec) -> some View {
185
+ let configuration: ScrollTransitionConfiguration = switch spec.configuration {
186
+ case "animated": .animated
187
+ case "identity": .identity
188
+ // Linear, as the protocol draws it (between phases by how far out the
189
+ // view is): SwiftUI's default curve, ease in and out, outruns the
190
+ // scroll mid-way, so an offset of most of the view's length (a book's
191
+ // page coming out from under the one turning) ran backwards.
192
+ default: .interactive(timingCurve: .linear)
193
+ }
194
+ let axis: Axis? = switch spec.axis {
195
+ case "horizontal": .horizontal
196
+ case "vertical": .vertical
197
+ default: nil
198
+ }
199
+ return scrollTransition(configuration, axis: axis) { content, phase in
200
+ let effect = phase.value < 0 ? spec.topLeading : phase.value > 0 ? spec.bottomTrailing : spec.identity
201
+ return content
202
+ .offset(x: effect.offset?.x ?? 0, y: effect.offset?.y ?? 0)
203
+ .scaleEffect(x: effect.scale?.x ?? 1, y: effect.scale?.y ?? 1, anchor: effect.scaleAnchor.map(UnitPoint.init(spec:)) ?? .center)
204
+ .rotationEffect(.degrees(effect.rotation ?? 0), anchor: effect.rotationAnchor.map(UnitPoint.init(spec:)) ?? .center)
205
+ .opacity(effect.opacity ?? 1)
206
+ .blur(radius: effect.blur ?? 0)
207
+ .brightness(effect.brightness ?? 0)
208
+ .contrast(effect.contrast ?? 1)
209
+ .saturation(effect.saturation ?? 1)
210
+ .grayscale(effect.grayscale ?? 0)
211
+ .hueRotation(.degrees(effect.hueRotation ?? 0))
212
+ }
213
+ }
214
+ }
215
+
175
216
  extension BlendMode {
176
217
  init?(name: String) {
177
218
  switch name {
@@ -12,8 +12,8 @@ public enum Op: Equatable, Sendable {
12
12
  case insert(parent: Int, id: Int, index: Int)
13
13
  /// Detach and destroy the element and its subtree.
14
14
  case remove(id: Int)
15
- /// End of a render pass; with `animation`, every change of the pass animates.
16
- case commit(animation: AnimationSpec?)
15
+ /// End of a render pass; with `animation`, every change of the pass animates; with `completion`, the app hears `animationcomplete` once it has.
16
+ case commit(animation: AnimationSpec?, completion: CommitCompletionSpec? = nil)
17
17
  /// Any other op, by name, with the whole object.
18
18
  case other(name: String, fields: JSONObject)
19
19
 
@@ -56,7 +56,7 @@ extension Op {
56
56
  case "remove":
57
57
  self = .remove(id: try id("id"))
58
58
  case "commit":
59
- self = .commit(animation: fields["animation"].flatMap(AnimationSpec.init(json:)))
59
+ self = .commit(animation: fields["animation"].flatMap(AnimationSpec.init(json:)), completion: CommitCompletionSpec(fields: fields))
60
60
  default:
61
61
  self = .other(name: name, fields: fields)
62
62
  }
@@ -89,12 +89,35 @@ extension Op {
89
89
  return .object(["op": "insert", "parent": .number(Double(parent)), "id": .number(Double(id)), "index": .number(Double(index))])
90
90
  case .remove(let id):
91
91
  return .object(["op": "remove", "id": .number(Double(id))])
92
- case .commit(let animation):
92
+ case .commit(let animation, let completion):
93
93
  var fields: JSONObject = ["op": "commit"]
94
94
  if let animation { fields["animation"] = animation.json }
95
+ if let completion {
96
+ fields["completion"] = .number(Double(completion.serial))
97
+ if completion.criteria != "logicallyComplete" { fields["completionCriteria"] = .string(completion.criteria) }
98
+ }
95
99
  return .object(fields)
96
100
  case .other(_, let fields):
97
101
  return .object(fields)
98
102
  }
99
103
  }
100
104
  }
105
+
106
+ /// `withAnimation(_:completionCriteria:_:completion:)` on a `commit`: the
107
+ /// serial the app's completion waits under, and when it should run
108
+ /// (`logicallyComplete`, the default, or `removed`).
109
+ public struct CommitCompletionSpec: Equatable, Sendable {
110
+ public var serial: Int
111
+ public var criteria: String
112
+
113
+ public init(serial: Int, criteria: String = "logicallyComplete") {
114
+ self.serial = serial
115
+ self.criteria = criteria
116
+ }
117
+
118
+ /// From a `commit`'s fields; nil without a `completion`.
119
+ public init?(fields: JSONObject) {
120
+ guard let serial = fields.int("completion") else { return nil }
121
+ self.init(serial: serial, criteria: fields.string("completionCriteria") ?? "logicallyComplete")
122
+ }
123
+ }
@@ -22,6 +22,8 @@ public struct TextRunSpec: Equatable, Sendable {
22
22
  public var image: ImageProps?
23
23
  public var font: FontSpec?
24
24
  public var foreground: ShapeStyleSpec?
25
+ /// `AttributedString`'s `backgroundColor`: a color behind the run's glyphs.
26
+ public var background: ColorSpec?
25
27
  public var italic: Bool
26
28
  public var underline: Bool
27
29
  public var underlineColor: ColorSpec?
@@ -37,6 +39,7 @@ public struct TextRunSpec: Equatable, Sendable {
37
39
  image = fields.object("image").map(ImageProps.init(props:))
38
40
  font = FontSpec(json: fields.value("font"))
39
41
  foreground = ShapeStyleSpec(json: fields.value("foreground"))
42
+ background = ColorSpec(json: fields.value("background"))
40
43
  italic = fields.bool("italic") ?? false
41
44
  underline = fields["underline"] != nil
42
45
  underlineColor = ColorSpec(json: fields.value("underline")["color"])
@@ -56,11 +59,14 @@ public struct StackProps: Equatable, Sendable {
56
59
 
57
60
  /// `.scrollTargetLayout()`: the stack's children are the snap points of the scroll view around it.
58
61
  public var scrollTarget: Bool
62
+ /// `LazyVStack(pinnedViews:)`: `sectionHeaders`, `sectionFooters`; empty for a plain stack.
63
+ public var pinned: [String]
59
64
 
60
65
  public init(props: JSONObject) {
61
66
  spacing = props.double("spacing")
62
67
  alignment = props.string("alignment") ?? "center"
63
68
  scrollTarget = props.bool("scrollTarget") ?? false
69
+ pinned = props.array("pinned")?.compactMap(\.string) ?? []
64
70
  }
65
71
  }
66
72
 
@@ -120,6 +126,12 @@ public struct ScrollViewProps: Equatable, Sendable {
120
126
  /// `.scrollPosition(id:)`: the view reports the marked element at its anchor as it settles.
121
127
  public var reportsPosition: Bool
122
128
  public var positionAnchor: UnitPointSpec?
129
+ /// `.onScrollGeometryChange`: the view sends `scrollgeometry` as it scrolls and lays out.
130
+ public var reportsGeometry: Bool
131
+ /// `ScrollPosition.scrollTo(x:y:)`: the content offset to scroll to, once per serial.
132
+ public var scrollOffset: ScrollOffsetSpec?
133
+ /// `.defaultScrollAnchor(_:)`: where the content starts, and where it stays as it grows.
134
+ public var defaultScrollAnchor: UnitPointSpec?
123
135
  public init(props: JSONObject) {
124
136
  axes = props.string("axes") ?? "vertical"
125
137
  showsIndicators = props.bool("showsIndicators") ?? true
@@ -127,6 +139,23 @@ public struct ScrollViewProps: Equatable, Sendable {
127
139
  snap = props.string("snap")
128
140
  reportsPosition = props.bool("reportsPosition") ?? false
129
141
  positionAnchor = UnitPointSpec(json: props.value("positionAnchor"))
142
+ reportsGeometry = props.bool("reportsGeometry") ?? false
143
+ scrollOffset = ScrollOffsetSpec(json: props.value("scrollOffset"))
144
+ defaultScrollAnchor = UnitPointSpec(json: props.value("defaultScrollAnchor"))
145
+ }
146
+ }
147
+
148
+ /// A `scrollview`'s `scrollOffset` command: where to scroll (an axis left out keeps its offset).
149
+ public struct ScrollOffsetSpec: Equatable, Sendable {
150
+ public var x: Double?
151
+ public var y: Double?
152
+ /// Increases with every request; carried out once each.
153
+ public var serial: Double
154
+ public init?(json: JSONValue) {
155
+ guard let fields = json.object, let serial = fields.double("serial") else { return nil }
156
+ self.serial = serial
157
+ x = fields.double("x")
158
+ y = fields.double("y")
130
159
  }
131
160
  }
132
161
 
@@ -160,14 +189,55 @@ public struct SectionProps: Equatable, Sendable {
160
189
  }
161
190
  }
162
191
 
192
+ /// `texteditor`: its text, a bound focus, and for the rich editor its runs and a bound selection.
193
+ public struct TextEditorProps: Equatable, Sendable {
194
+ public var text: String
195
+ public var focused: Bool?
196
+ /// `deleteBackward`: Backspace at the start of the text sends `deletebackward`.
197
+ public var deleteBackward: Bool
198
+ /// Rich text (`runs`, an empty array included): the runs `text` is made of; nil for a plain editor.
199
+ public var runs: [TextRunSpec]?
200
+ /// `selection` in the props: the editor reports the viewer's selection (UTF-16 offsets).
201
+ public var selection: TextSelectionSpec?
202
+
203
+ public var rich: Bool { runs != nil }
204
+
205
+ public init(props: JSONObject) {
206
+ text = props.string("text") ?? ""
207
+ focused = props.bool("focused")
208
+ deleteBackward = props.bool("deleteBackward") ?? false
209
+ runs = props.array("runs")?.compactMap(TextRunSpec.init(json:))
210
+ selection = TextSelectionSpec(json: props.value("selection"))
211
+ }
212
+ }
213
+
214
+ /// A range of an editor's text in UTF-16 offsets; `start == end` is the caret.
215
+ public struct TextSelectionSpec: Equatable, Sendable {
216
+ public var start: Int
217
+ public var end: Int
218
+
219
+ public init(start: Int, end: Int) {
220
+ self.start = start
221
+ self.end = end
222
+ }
223
+
224
+ public init?(json: JSONValue) {
225
+ guard let fields = json.object, let start = fields.int("start"), let end = fields.int("end") else { return nil }
226
+ self.init(start: start, end: end)
227
+ }
228
+ }
229
+
163
230
  public struct TextFieldProps: Equatable, Sendable {
164
231
  public var text: String
165
232
  public var placeholder: String
166
233
  /// `plain` or `roundedBorder`.
167
234
  public var style: String
168
235
  public var secure: Bool
236
+ /// A bound focus; after a `submit` the value the app's pass leaves wins over Return's resign (`TextFieldElement`).
169
237
  public var focused: Bool?
170
238
  public var submit: Bool
239
+ /// `deleteBackward`: Backspace at the start of the text sends `deletebackward`.
240
+ public var deleteBackward: Bool
171
241
  public var submitLabel: String?
172
242
  public var vertical: Bool
173
243
  public init(props: JSONObject) {
@@ -177,6 +247,7 @@ public struct TextFieldProps: Equatable, Sendable {
177
247
  secure = props.bool("secure") ?? false
178
248
  focused = props.bool("focused")
179
249
  submit = props.bool("submit") ?? false
250
+ deleteBackward = props.bool("deleteBackward") ?? false
180
251
  submitLabel = props.string("submitLabel")
181
252
  vertical = props.string("axis") == "vertical"
182
253
  }
@@ -184,7 +255,12 @@ public struct TextFieldProps: Equatable, Sendable {
184
255
 
185
256
  public struct ToggleProps: Equatable, Sendable {
186
257
  public var isOn: Bool
187
- public init(props: JSONObject) { isOn = props.bool("isOn") ?? false }
258
+ /// `automatic`, `switch` or `button` (`.toggleStyle`).
259
+ public var style: String
260
+ public init(props: JSONObject) {
261
+ isOn = props.bool("isOn") ?? false
262
+ style = props.string("style") ?? "automatic"
263
+ }
188
264
  }
189
265
 
190
266
  public struct NavScreenProps: Equatable, Sendable {
@@ -229,12 +305,22 @@ public struct StrokeSpec: Equatable, Sendable {
229
305
  public var lineWidth: Double
230
306
  public var inset: Bool
231
307
  public var dash: [Double]
308
+ public var dashPhase: Double
309
+ /// `butt`, `round` or `square`.
310
+ public var lineCap: String
311
+ /// `miter`, `round` or `bevel`.
312
+ public var lineJoin: String
313
+ public var miterLimit: Double
232
314
  public init?(json: JSONValue) {
233
315
  guard let fields = json.object else { return nil }
234
316
  style = ShapeStyleSpec(json: fields.value("color"))
235
317
  lineWidth = fields.double("lineWidth") ?? 1
236
318
  inset = fields.bool("inset") ?? false
237
319
  dash = (fields.array("dash") ?? []).compactMap(\.double)
320
+ dashPhase = fields.double("dashPhase") ?? 0
321
+ lineCap = fields.string("lineCap") ?? "butt"
322
+ lineJoin = fields.string("lineJoin") ?? "miter"
323
+ miterLimit = fields.double("miterLimit") ?? 10
238
324
  }
239
325
  }
240
326
 
@@ -332,7 +418,7 @@ public struct ListRowProps: Equatable, Sendable {
332
418
  }
333
419
 
334
420
  public struct ToolbarProps: Equatable, Sendable {
335
- /// `trailing`, `leading`, `principal`, `bottom`, `secondary`, `largeTitle`.
421
+ /// `trailing`, `leading`, `principal`, `bottom`, `secondary`, `largeTitle`, `keyboard`.
336
422
  public var placement: String
337
423
  /// A `ToolbarSpacer`: `fixed` or `flexible`; the element has no items.
338
424
  public var spacer: String?
@@ -443,7 +529,12 @@ public struct SliderProps: Equatable, Sendable {
443
529
 
444
530
  public struct TapGestureProps: Equatable, Sendable {
445
531
  public var location: Bool
446
- public init(props: JSONObject) { location = props.bool("location") ?? false }
532
+ /// `.onTapGesture(count:)`: the taps it takes, 1 by default.
533
+ public var count: Int
534
+ public init(props: JSONObject) {
535
+ location = props.bool("location") ?? false
536
+ count = max(1, Int(props.double("count") ?? 1))
537
+ }
447
538
  }
448
539
 
449
540
  /// The producer-only `native` kind (docs/native.md, "The native escape hatch").
@@ -500,13 +591,29 @@ public struct DatePickerProps: Equatable, Sendable {
500
591
  public struct GestureProps: Equatable, Sendable {
501
592
  public var drag: Bool
502
593
  public var longPress: Bool
594
+ /// Two fingers spreading or pinching (`MagnifyGesture`), and turning (`RotateGesture`): `pinch` events.
595
+ public var magnify: Bool
596
+ public var rotate: Bool
503
597
  public var minimumDistance: Double
504
598
  public var minimumDuration: Double
599
+ public var minimumScaleDelta: Double
600
+ /// Degrees.
601
+ public var minimumAngleDelta: Double
602
+ /// The space of a drag's `location` and `startLocation`: `local` (the element's own) or `global`.
603
+ public var coordinateSpace: String
604
+ /// `simultaneous` (`.simultaneousGesture`), `highPriority` (`.highPriorityGesture`), or nil for `.gesture`.
605
+ public var mode: String?
505
606
  public init(props: JSONObject) {
506
607
  drag = props.bool("drag") ?? false
507
608
  longPress = props.bool("longPress") ?? false
609
+ magnify = props.bool("magnify") ?? false
610
+ rotate = props.bool("rotate") ?? false
508
611
  minimumDistance = props.double("minimumDistance") ?? 10
509
612
  minimumDuration = props.double("minimumDuration") ?? 0.5
613
+ minimumScaleDelta = props.double("minimumScaleDelta") ?? 0.01
614
+ minimumAngleDelta = props.double("minimumAngleDelta") ?? 1
615
+ coordinateSpace = props.string("coordinateSpace") ?? "local"
616
+ mode = props.string("mode")
510
617
  }
511
618
  }
512
619
 
@@ -75,6 +75,14 @@ public struct RendererEvent: Equatable, Sendable {
75
75
  public static func focus(_ id: Int, _ focused: Bool) -> RendererEvent { with("focus", id: id, ["value": .bool(focused)]) }
76
76
  /// The Return key in a `textfield` with `submit`.
77
77
  public static func submit(_ id: Int) -> RendererEvent { with("submit", id: id) }
78
+ /// Backspace at the start of a `textfield` or `texteditor` with `deleteBackward`, nothing selected.
79
+ public static func deleteBackward(_ id: Int) -> RendererEvent { with("deletebackward", id: id) }
80
+ /// The viewer's selection in a `texteditor` with `selection`, in UTF-16 offsets.
81
+ public static func selection(_ id: Int, start: Int, end: Int) -> RendererEvent {
82
+ with("selection", id: id, ["value": .object(["start": .number(Double(start)), "end": .number(Double(end))])])
83
+ }
84
+ /// A rich `texteditor`'s runs after the editor's own formatting changed their attributes alone (docs/ops-protocol.md, "Rich text editors"): the text unchanged, `runs` as the editor shows it.
85
+ public static func runs(_ id: Int, _ runs: [JSONValue]) -> RendererEvent { with("runs", id: id, ["value": .array(runs)]) }
78
86
  /// A `listrow` was deleted by a swipe or in edit mode.
79
87
  public static func delete(_ id: Int) -> RendererEvent { with("delete", id: id) }
80
88
  /// A `listrow` was moved to index `to` among its siblings.
@@ -92,6 +100,28 @@ public struct RendererEvent: Equatable, Sendable {
92
100
  "velocity": .object(["width": .number(velocity.width), "height": .number(velocity.height)]),
93
101
  ])
94
102
  }
103
+ /// A `gesture` with `magnify` or `rotate`: two fingers since `began`. `scale`
104
+ /// is the magnification (1 at the start), `rotation` the turn in degrees,
105
+ /// clockwise positive, the velocities per second; `startLocation` where the
106
+ /// fingers began, in the element's coordinates, `startAnchor` the same as a
107
+ /// fraction of its size, `location` where their midpoint is now (left out
108
+ /// when the renderer cannot tell).
109
+ public static func pinch(
110
+ _ id: Int, phase: String, scale: Double, rotation: Double, scaleVelocity: Double, rotationVelocity: Double,
111
+ startLocation: (x: Double, y: Double), startAnchor: (x: Double, y: Double), location: (x: Double, y: Double)? = nil
112
+ ) -> RendererEvent {
113
+ var fields: JSONObject = [
114
+ "phase": .string(phase),
115
+ "scale": .number(scale),
116
+ "rotation": .number(rotation),
117
+ "scaleVelocity": .number(scaleVelocity),
118
+ "rotationVelocity": .number(rotationVelocity),
119
+ "startLocation": .object(["x": .number(startLocation.x), "y": .number(startLocation.y)]),
120
+ "startAnchor": .object(["x": .number(startAnchor.x), "y": .number(startAnchor.y)]),
121
+ ]
122
+ if let location { fields["location"] = .object(["x": .number(location.x), "y": .number(location.y)]) }
123
+ return with("pinch", id: id, fields)
124
+ }
95
125
  /// A `lazyspacer` came into view: the app mounts the next batch of rows.
96
126
  public static func lazy(_ id: Int) -> RendererEvent { with("lazy", id: id) }
97
127
  /// A `gesture` long press.
@@ -100,6 +130,23 @@ public struct RendererEvent: Equatable, Sendable {
100
130
  public static func refresh(_ id: Int) -> RendererEvent { with("refresh", id: id) }
101
131
  /// `.scrollPosition(id:)`: the marked element at the scroll view's anchor once it settled.
102
132
  public static func scrollPosition(_ id: Int) -> RendererEvent { with("scrollposition", id: id) }
133
+ /// `.onScrollGeometryChange`: a `scrollview` with `reportsGeometry` scrolled or laid out (points, 2 decimals).
134
+ public static func scrollGeometry(
135
+ _ id: Int, offset: (x: Double, y: Double), contentSize: (width: Double, height: Double),
136
+ containerSize: (width: Double, height: Double), contentInsets: (top: Double, leading: Double, bottom: Double, trailing: Double)
137
+ ) -> RendererEvent {
138
+ let r = { (value: Double) in JSONValue.number((value * 100).rounded() / 100) }
139
+ return with("scrollgeometry", id: id, [
140
+ "offset": .object(["x": r(offset.x), "y": r(offset.y)]),
141
+ "contentSize": .object(["width": r(contentSize.width), "height": r(contentSize.height)]),
142
+ "containerSize": .object(["width": r(containerSize.width), "height": r(containerSize.height)]),
143
+ "contentInsets": .object(["top": r(contentInsets.top), "leading": r(contentInsets.leading), "bottom": r(contentInsets.bottom), "trailing": r(contentInsets.trailing)]),
144
+ ])
145
+ }
146
+ /// The pass whose `commit` carried this `completion` serial has finished animating (`withAnimation`'s completion).
147
+ public static func animationComplete(serial: Int) -> RendererEvent {
148
+ RendererEvent(type: "animationcomplete", fields: ["serial": .number(Double(serial))])
149
+ }
103
150
  /// A `menu` opened or closed (lazy menus build their items on `open`).
104
151
  public static func menu(_ id: Int, open: Bool) -> RendererEvent { with("menu", id: id, ["open": .bool(open)]) }
105
152
  /// The host's answer to a `native` element's own event (docs/native.md, the escape hatch).
@@ -0,0 +1,260 @@
1
+ import Foundation
2
+
3
+ /// SVG path data, as the protocol carries a custom shape's path (`M`, `L`,
4
+ /// `H`, `V`, `C`, `S`, `Q`, `T`, `A`, `Z` and their relative forms): parsed
5
+ /// into moves, lines, curves and closes in absolute coordinates, arcs as
6
+ /// cubic curves, for a renderer to build its own path from. SwiftUI's
7
+ /// `Path(_:)` reads only `Path.description`'s form, not SVG.
8
+ public enum SVGPath {
9
+ public struct Point: Equatable, Sendable {
10
+ public var x: Double
11
+ public var y: Double
12
+ public init(x: Double, y: Double) {
13
+ self.x = x
14
+ self.y = y
15
+ }
16
+ }
17
+
18
+ public enum Element: Equatable, Sendable {
19
+ case move(Point)
20
+ case line(Point)
21
+ case quadCurve(to: Point, control: Point)
22
+ case curve(to: Point, control1: Point, control2: Point)
23
+ case closeSubpath
24
+ }
25
+
26
+ /// The elements of `data`; nil when it is not path data (an unknown
27
+ /// command, a number missing). Empty data is no elements.
28
+ public static func parse(_ data: String) -> [Element]? {
29
+ var scanner = PathScanner(Array(data.utf8))
30
+ var elements: [Element] = []
31
+ var current = Point(x: 0, y: 0)
32
+ var start = current
33
+ // The last curve's second control point, for `S` and `T`.
34
+ var lastCubic: Point?
35
+ var lastQuad: Point?
36
+ var command: UInt8?
37
+
38
+ while true {
39
+ scanner.skipSeparators()
40
+ guard let byte = scanner.peek else { break }
41
+ if PathScanner.isCommand(byte) {
42
+ command = byte
43
+ scanner.index += 1
44
+ } else if command == nil {
45
+ return nil
46
+ }
47
+ guard let op = command else { return nil }
48
+ let relative = op >= UInt8(ascii: "a")
49
+ let base = relative ? current : Point(x: 0, y: 0)
50
+ func point() -> Point? {
51
+ guard let x = scanner.number(), let y = scanner.number() else { return nil }
52
+ return Point(x: base.x + x, y: base.y + y)
53
+ }
54
+ var cubic: Point?
55
+ var quad: Point?
56
+ switch op | 0x20 {
57
+ case UInt8(ascii: "m"):
58
+ guard let to = point() else { return nil }
59
+ elements.append(.move(to))
60
+ current = to
61
+ start = to
62
+ // Pairs after a move are lines.
63
+ command = relative ? UInt8(ascii: "l") : UInt8(ascii: "L")
64
+ case UInt8(ascii: "l"):
65
+ guard let to = point() else { return nil }
66
+ elements.append(.line(to))
67
+ current = to
68
+ case UInt8(ascii: "h"):
69
+ guard let x = scanner.number() else { return nil }
70
+ current = Point(x: relative ? current.x + x : x, y: current.y)
71
+ elements.append(.line(current))
72
+ case UInt8(ascii: "v"):
73
+ guard let y = scanner.number() else { return nil }
74
+ current = Point(x: current.x, y: relative ? current.y + y : y)
75
+ elements.append(.line(current))
76
+ case UInt8(ascii: "c"):
77
+ guard let c1 = point(), let c2 = point(), let to = point() else { return nil }
78
+ elements.append(.curve(to: to, control1: c1, control2: c2))
79
+ current = to
80
+ cubic = c2
81
+ case UInt8(ascii: "s"):
82
+ guard let c2 = point(), let to = point() else { return nil }
83
+ let c1 = lastCubic.map { Point(x: 2 * current.x - $0.x, y: 2 * current.y - $0.y) } ?? current
84
+ elements.append(.curve(to: to, control1: c1, control2: c2))
85
+ current = to
86
+ cubic = c2
87
+ case UInt8(ascii: "q"):
88
+ guard let c = point(), let to = point() else { return nil }
89
+ elements.append(.quadCurve(to: to, control: c))
90
+ current = to
91
+ quad = c
92
+ case UInt8(ascii: "t"):
93
+ guard let to = point() else { return nil }
94
+ let c = lastQuad.map { Point(x: 2 * current.x - $0.x, y: 2 * current.y - $0.y) } ?? current
95
+ elements.append(.quadCurve(to: to, control: c))
96
+ current = to
97
+ quad = c
98
+ case UInt8(ascii: "a"):
99
+ guard let rx = scanner.number(), let ry = scanner.number(), let rotation = scanner.number(),
100
+ let large = scanner.flag(), let sweep = scanner.flag(), let to = point() else { return nil }
101
+ elements += arc(from: current, to: to, rx: rx, ry: ry, rotation: rotation, large: large, sweep: sweep)
102
+ current = to
103
+ case UInt8(ascii: "z"):
104
+ elements.append(.closeSubpath)
105
+ current = start
106
+ // A close takes no numbers: the next byte must be a command.
107
+ command = nil
108
+ default:
109
+ return nil
110
+ }
111
+ lastCubic = cubic
112
+ lastQuad = quad
113
+ }
114
+ return elements
115
+ }
116
+
117
+ /// An SVG arc (endpoint form) as cubic curves of a quarter turn or less
118
+ /// (SVG 1.1, F.6.5 and F.6.6). Equal endpoints draw nothing; a zero
119
+ /// radius is a line.
120
+ static func arc(from p0: Point, to p1: Point, rx rxIn: Double, ry ryIn: Double, rotation: Double, large: Bool, sweep: Bool) -> [Element] {
121
+ if p0 == p1 { return [] }
122
+ var rx = abs(rxIn)
123
+ var ry = abs(ryIn)
124
+ if rx == 0 || ry == 0 { return [.line(p1)] }
125
+ let phi = rotation * .pi / 180
126
+ let cosPhi = cos(phi)
127
+ let sinPhi = sin(phi)
128
+ let dx = (p0.x - p1.x) / 2
129
+ let dy = (p0.y - p1.y) / 2
130
+ let x1 = cosPhi * dx + sinPhi * dy
131
+ let y1 = -sinPhi * dx + cosPhi * dy
132
+ // Radii too small for the endpoints are scaled up until they fit.
133
+ let lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry)
134
+ if lambda > 1 {
135
+ rx *= lambda.squareRoot()
136
+ ry *= lambda.squareRoot()
137
+ }
138
+ let numerator = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1
139
+ let denominator = rx * rx * y1 * y1 + ry * ry * x1 * x1
140
+ var factor = denominator == 0 ? 0 : (max(0, numerator) / denominator).squareRoot()
141
+ if large == sweep { factor = -factor }
142
+ let cx1 = factor * rx * y1 / ry
143
+ let cy1 = -factor * ry * x1 / rx
144
+ let cx = cosPhi * cx1 - sinPhi * cy1 + (p0.x + p1.x) / 2
145
+ let cy = sinPhi * cx1 + cosPhi * cy1 + (p0.y + p1.y) / 2
146
+ func angle(_ ux: Double, _ uy: Double, _ vx: Double, _ vy: Double) -> Double {
147
+ let sign: Double = ux * vy - uy * vx < 0 ? -1 : 1
148
+ let dot = ux * vx + uy * vy
149
+ let length = (ux * ux + uy * uy).squareRoot() * (vx * vx + vy * vy).squareRoot()
150
+ return sign * acos(min(1, max(-1, dot / length)))
151
+ }
152
+ let theta1 = angle(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry)
153
+ var delta = angle((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry)
154
+ if !sweep && delta > 0 { delta -= 2 * .pi }
155
+ if sweep && delta < 0 { delta += 2 * .pi }
156
+
157
+ let segments = max(1, Int((abs(delta) / (.pi / 2)).rounded(.up)))
158
+ let step = delta / Double(segments)
159
+ let k = 4.0 / 3.0 * tan(step / 4)
160
+ func onEllipse(_ t: Double) -> Point {
161
+ let x = rx * cos(t)
162
+ let y = ry * sin(t)
163
+ return Point(x: cx + cosPhi * x - sinPhi * y, y: cy + sinPhi * x + cosPhi * y)
164
+ }
165
+ func tangent(_ t: Double) -> Point {
166
+ let x = -rx * sin(t)
167
+ let y = ry * cos(t)
168
+ return Point(x: cosPhi * x - sinPhi * y, y: sinPhi * x + cosPhi * y)
169
+ }
170
+ var elements: [Element] = []
171
+ var t = theta1
172
+ var from = p0
173
+ for index in 0..<segments {
174
+ let next = t + step
175
+ // The last end is the endpoint itself, not its recomputation.
176
+ let to = index == segments - 1 ? p1 : onEllipse(next)
177
+ let d0 = tangent(t)
178
+ let d1 = tangent(next)
179
+ elements.append(.curve(
180
+ to: to,
181
+ control1: Point(x: from.x + k * d0.x, y: from.y + k * d0.y),
182
+ control2: Point(x: to.x - k * d1.x, y: to.y - k * d1.y)
183
+ ))
184
+ from = to
185
+ t = next
186
+ }
187
+ return elements
188
+ }
189
+ }
190
+
191
+ /// Numbers and flags out of path data's bytes.
192
+ private struct PathScanner {
193
+ let bytes: [UInt8]
194
+ var index = 0
195
+
196
+ init(_ bytes: [UInt8]) {
197
+ self.bytes = bytes
198
+ }
199
+
200
+ var peek: UInt8? { index < bytes.count ? bytes[index] : nil }
201
+
202
+ static func isCommand(_ byte: UInt8) -> Bool {
203
+ switch byte | 0x20 {
204
+ case UInt8(ascii: "m"), UInt8(ascii: "l"), UInt8(ascii: "h"), UInt8(ascii: "v"), UInt8(ascii: "c"), UInt8(ascii: "s"),
205
+ UInt8(ascii: "q"), UInt8(ascii: "t"), UInt8(ascii: "a"), UInt8(ascii: "z"):
206
+ return true
207
+ default:
208
+ return false
209
+ }
210
+ }
211
+
212
+ mutating func skipSeparators() {
213
+ while let byte = peek, byte == UInt8(ascii: " ") || byte == UInt8(ascii: ",") || byte == 0x0A || byte == 0x0D || byte == 0x09 {
214
+ index += 1
215
+ }
216
+ }
217
+
218
+ /// A number: sign, digits, a point, an exponent (`-1.5e-3`, `.5`; `1.5.5` is two).
219
+ mutating func number() -> Double? {
220
+ skipSeparators()
221
+ let begin = index
222
+ if let byte = peek, byte == UInt8(ascii: "-") || byte == UInt8(ascii: "+") { index += 1 }
223
+ var digits = false
224
+ var point = false
225
+ while let byte = peek {
226
+ if byte >= UInt8(ascii: "0") && byte <= UInt8(ascii: "9") {
227
+ digits = true
228
+ } else if byte == UInt8(ascii: "."), !point {
229
+ point = true
230
+ } else {
231
+ break
232
+ }
233
+ index += 1
234
+ }
235
+ guard digits else {
236
+ index = begin
237
+ return nil
238
+ }
239
+ if let byte = peek, byte == UInt8(ascii: "e") || byte == UInt8(ascii: "E") {
240
+ let mark = index
241
+ index += 1
242
+ if let sign = peek, sign == UInt8(ascii: "-") || sign == UInt8(ascii: "+") { index += 1 }
243
+ var exponent = false
244
+ while let byte = peek, byte >= UInt8(ascii: "0") && byte <= UInt8(ascii: "9") {
245
+ exponent = true
246
+ index += 1
247
+ }
248
+ if !exponent { index = mark }
249
+ }
250
+ return Double(String(decoding: bytes[begin..<index], as: UTF8.self))
251
+ }
252
+
253
+ /// An arc flag: one `0` or `1`, which may run into the next number (`a5 5 0 011 1`).
254
+ mutating func flag() -> Bool? {
255
+ skipSeparators()
256
+ guard let byte = peek, byte == UInt8(ascii: "0") || byte == UInt8(ascii: "1") else { return nil }
257
+ index += 1
258
+ return byte == UInt8(ascii: "1")
259
+ }
260
+ }