@swiftbrowser/native 0.17.7 → 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 (100) hide show
  1. package/README.md +99 -11
  2. package/dist/blob.d.ts +60 -0
  3. package/dist/blob.d.ts.map +1 -0
  4. package/dist/blob.js +153 -0
  5. package/dist/blob.js.map +1 -0
  6. package/dist/bridge.d.ts +65 -1
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +11 -3
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +26 -2
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +55 -0
  14. package/dist/hostRequests.d.ts.map +1 -0
  15. package/dist/hostRequests.js +196 -0
  16. package/dist/hostRequests.js.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/matchMedia.d.ts +43 -0
  22. package/dist/matchMedia.d.ts.map +1 -0
  23. package/dist/matchMedia.js +63 -0
  24. package/dist/matchMedia.js.map +1 -0
  25. package/dist/notifications.d.ts +108 -0
  26. package/dist/notifications.d.ts.map +1 -0
  27. package/dist/notifications.js +251 -0
  28. package/dist/notifications.js.map +1 -0
  29. package/dist/run.d.ts.map +1 -1
  30. package/dist/run.js +23 -2
  31. package/dist/run.js.map +1 -1
  32. package/dist/runtime.d.ts +2 -0
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +69 -0
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/secureStorage.d.ts +20 -0
  37. package/dist/secureStorage.d.ts.map +1 -0
  38. package/dist/secureStorage.js +28 -0
  39. package/dist/secureStorage.js.map +1 -0
  40. package/dist/site.d.ts +3 -3
  41. package/dist/site.d.ts.map +1 -1
  42. package/dist/site.js +1 -1
  43. package/dist/site.js.map +1 -1
  44. package/dist/subtle.d.ts +38 -0
  45. package/dist/subtle.d.ts.map +1 -0
  46. package/dist/subtle.js +72 -0
  47. package/dist/subtle.js.map +1 -0
  48. package/dist/undo.d.ts +23 -0
  49. package/dist/undo.d.ts.map +1 -0
  50. package/dist/undo.js +44 -0
  51. package/dist/undo.js.map +1 -0
  52. package/dist/webSocket.d.ts +73 -0
  53. package/dist/webSocket.d.ts.map +1 -0
  54. package/dist/webSocket.js +145 -0
  55. package/dist/webSocket.js.map +1 -0
  56. package/package.json +3 -3
  57. package/src/blob.ts +186 -0
  58. package/src/bridge.ts +74 -1
  59. package/src/fetch.ts +29 -3
  60. package/src/hostRequests.ts +216 -0
  61. package/src/index.ts +8 -0
  62. package/src/matchMedia.ts +79 -0
  63. package/src/notifications.ts +317 -0
  64. package/src/run.ts +21 -2
  65. package/src/runtime.ts +71 -0
  66. package/src/secureStorage.ts +39 -0
  67. package/src/site.ts +3 -3
  68. package/src/subtle.ts +87 -0
  69. package/src/undo.ts +47 -0
  70. package/src/webSocket.ts +175 -0
  71. package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
  72. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
  73. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +211 -0
  74. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +451 -0
  75. package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
  76. package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
  77. package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
  78. package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
  79. package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
  80. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
  81. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +56 -0
  82. package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
  83. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +362 -16
  84. package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
  85. package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
  86. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
  87. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
  88. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
  89. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
  90. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
  91. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
  92. package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
  93. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
  94. package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
  95. package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
  96. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
  97. package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
  98. package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
  99. package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
  100. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -125,7 +125,10 @@ final class VideoController {
125
125
  if player.isMuted != props.muted { player.isMuted = props.muted }
126
126
  let volume = Float(props.volume)
127
127
  if player.volume != volume { player.volume = volume }
128
- if props.report != applied?.report { observeTime(every: props.report ?? 0.25) }
128
+ // The first apply installs the observer whether or not `report` is set
129
+ // (`applied?.report` is nil both before any apply and for an app that
130
+ // sends none, as @swiftbrowser/react does); a new interval replaces it.
131
+ if timeObserver == nil || props.report != applied?.report { observeTime(every: props.report ?? 0.25) }
129
132
  if props.seekSerial != applied?.seekSerial {
130
133
  seek(to: props.seekTime, serial: props.seekSerial)
131
134
  }
@@ -173,7 +176,8 @@ final class VideoController {
173
176
  if let url { report("error", ["url": .string(url), "code": .number(4), "message": .string("not a URL: \(url)")]) }
174
177
  return
175
178
  }
176
- let item = AVPlayerItem(url: parsed)
179
+ // A file the app keeps (a recording) may sit under a moved container.
180
+ let item = AVPlayerItem(url: AppFiles.current(parsed))
177
181
  player.replaceCurrentItem(with: item)
178
182
  let center = NotificationCenter.default
179
183
  notifications.append(center.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main) { [weak self] _ in
@@ -279,7 +283,8 @@ final class VideoController {
279
283
 
280
284
  private func observeTime(every interval: Double) {
281
285
  if let timeObserver { player.removeTimeObserver(timeObserver) }
282
- let step = max(0.05, interval)
286
+ // A frame at most (`report: 1 / 60`, Voice Memos' scrubber moving with the sound).
287
+ let step = max(1.0 / 60.0, interval)
283
288
  timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: step, preferredTimescale: 600), queue: .main) { [weak self] time in
284
289
  Task { @MainActor in
285
290
  guard let self, self.applied?.playing == true else { return }
@@ -13,6 +13,9 @@ public struct NativeRootView: View {
13
13
  @Environment(\.colorScheme) private var colorScheme
14
14
  @Environment(\.dynamicTypeSize) private var dynamicTypeSize
15
15
  @Environment(\.scenePhase) private var scenePhase
16
+ #if !canImport(FoundationBridge)
17
+ @Environment(\.undoManager) private var undoManager
18
+ #endif
16
19
  #if os(iOS) || canImport(FoundationBridge)
17
20
  @Environment(\.horizontalSizeClass) private var horizontalSizeClass
18
21
  private var sizeClassName: String { horizontalSizeClass == .regular ? "regular" : "compact" }
@@ -52,6 +55,11 @@ public struct NativeRootView: View {
52
55
  observed = AnyView(observed.onChange(of: app.isLoaded) { _, loaded in
53
56
  if loaded { sendEnvironment() }
54
57
  })
58
+ #if !canImport(FoundationBridge)
59
+ // The window's undo manager, which mirrors the app's (UndoBridge.swift).
60
+ observed = AnyView(observed.onAppear { app.setUndoManager(undoManager) })
61
+ observed = AnyView(observed.onChange(of: undoManager) { _, manager in app.setUndoManager(manager) })
62
+ #endif
55
63
  return observed
56
64
  }
57
65
 
@@ -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).