@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
@@ -0,0 +1,84 @@
1
+ #if canImport(SwiftUI)
2
+ import SwiftBrowserProtocol
3
+ import SwiftBrowserTree
4
+ import SwiftUI
5
+
6
+ /// A screen's `keyboard` toolbars (`ToolbarItemGroup(placement: .keyboard)`)
7
+ /// as a bottom safe-area inset of its content, drawn by `KeyboardBar` on
8
+ /// iOS; elsewhere, and against the shim, the content is left as it is and
9
+ /// the toolbars go to SwiftUI's own placement.
10
+ struct KeyboardBarInset: ViewModifier {
11
+ let toolbars: [ElementNode]
12
+
13
+ func body(content: Content) -> some View {
14
+ #if os(iOS) && !canImport(FoundationBridge)
15
+ if toolbars.contains(where: { ToolbarProps(props: $0.props).spacer == nil }) {
16
+ content.safeAreaInset(edge: .bottom, spacing: 0) { KeyboardBar(toolbars: toolbars) }
17
+ } else {
18
+ content
19
+ }
20
+ #else
21
+ content
22
+ #endif
23
+ }
24
+ }
25
+
26
+ #if os(iOS) && !canImport(FoundationBridge)
27
+ import Combine
28
+ import UIKit
29
+
30
+ /// The bar above the keyboard as Notes draws its format bar on iOS 26: a
31
+ /// glass capsule 48 tall, inset 10 from the screen's sides, floating 10
32
+ /// above the keyboard, there while the keyboard is up and gone with it.
33
+ /// SwiftUI's `.keyboard` placement is the input accessory view, a bar flush
34
+ /// with the keyboard across the screen, which the system's apps no longer
35
+ /// show. The items are the screen's `keyboard` toolbars' children in one
36
+ /// row, a `spacer` toolbar a `Spacer` (8 wide when fixed), as the page lays
37
+ /// them out (docs/ops-protocol.md, "The keyboard toolbar"). As a safe-area
38
+ /// inset of the content, the bar keeps the content above it, as the
39
+ /// keyboard does; with the keyboard down it has no height.
40
+ struct KeyboardBar: View {
41
+ let toolbars: [ElementNode]
42
+ @State private var shown = false
43
+
44
+ var body: some View {
45
+ Group {
46
+ if shown {
47
+ HStack(spacing: 0) {
48
+ ForEach(toolbars) { toolbar in
49
+ if let spacer = ToolbarProps(props: toolbar.props).spacer {
50
+ if spacer == "fixed" { Spacer().frame(width: 8) } else { Spacer(minLength: 0) }
51
+ } else {
52
+ ChildrenView(node: toolbar)
53
+ }
54
+ }
55
+ }
56
+ .padding(.horizontal, 16)
57
+ .frame(maxWidth: .infinity, minHeight: 48)
58
+ .modifier(CapsuleGlass())
59
+ .padding(.horizontal, 10)
60
+ .padding(.bottom, 10)
61
+ .transition(.move(edge: .bottom).combined(with: .opacity))
62
+ }
63
+ }
64
+ .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
65
+ withAnimation(.easeOut(duration: 0.25)) { shown = true }
66
+ }
67
+ .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
68
+ withAnimation(.easeOut(duration: 0.25)) { shown = false }
69
+ }
70
+ }
71
+ }
72
+
73
+ /// Liquid Glass from iOS 26; a material before it.
74
+ private struct CapsuleGlass: ViewModifier {
75
+ func body(content: Content) -> some View {
76
+ if #available(iOS 26, *) {
77
+ content.glassEffect(.regular, in: .capsule)
78
+ } else {
79
+ content.background(.regularMaterial, in: Capsule())
80
+ }
81
+ }
82
+ }
83
+ #endif
84
+ #endif
@@ -77,19 +77,28 @@ struct DatePickerElement: View {
77
77
  }
78
78
  }
79
79
 
80
- /// `gesture`: one child; a drag sends `drag` events through its phases and
81
- /// a long press sends `longpress`.
80
+ /// `gesture`: one child; a drag sends `drag` events through its phases, a
81
+ /// long press sends `longpress`, and two fingers spreading (`magnify`) or
82
+ /// turning (`rotate`) send `pinch` events. The pinches go alongside the
83
+ /// view's other gestures (`simultaneousGesture`), so a canvas's one-finger
84
+ /// pan inside a two-finger zoom does not keep the zoom from starting. The
85
+ /// drag and the long press attach as `mode` says (`.gesture`,
86
+ /// `.highPriorityGesture` or `.simultaneousGesture`), and the drag's points
87
+ /// are in `coordinateSpace` (`.local` or `.global`).
82
88
  struct GestureElement: View {
83
89
  let node: ElementNode
84
90
  @Environment(\.nativeApp) private var app
85
91
  @State private var dragging = false
92
+ @State private var magnifying = false
93
+ @State private var rotating = false
94
+ @State private var fingers = Fingers()
86
95
 
87
96
  var body: some View {
88
97
  let props = GestureProps(props: node.props)
89
- let content = ChildrenView(node: node).contentShape(Rectangle())
98
+ let content = AnyView(ChildrenView(node: node).contentShape(Rectangle()))
90
99
  let dragged: AnyView = props.drag
91
- ? AnyView(content.gesture(
92
- DragGesture(minimumDistance: props.minimumDistance)
100
+ ? attach(
101
+ DragGesture(minimumDistance: props.minimumDistance, coordinateSpace: props.coordinateSpace == "global" ? .global : .local)
93
102
  .onChanged { value in
94
103
  let phase = dragging ? "changed" : "began"
95
104
  dragging = true
@@ -98,18 +107,99 @@ struct GestureElement: View {
98
107
  .onEnded { value in
99
108
  dragging = false
100
109
  send(value, phase: "ended")
110
+ },
111
+ to: content, mode: props.mode
112
+ )
113
+ : content
114
+ let pressed: AnyView = props.longPress
115
+ ? attach(
116
+ LongPressGesture(minimumDuration: props.minimumDuration).onEnded { _ in app?.dispatch(.longPress(node.id)) },
117
+ to: dragged, mode: props.mode
118
+ )
119
+ : dragged
120
+ let tracked = props.magnify || props.rotate ? trackingFingers(pressed) : pressed
121
+ let magnified: AnyView = props.magnify
122
+ ? AnyView(tracked.simultaneousGesture(
123
+ MagnifyGesture(minimumScaleDelta: props.minimumScaleDelta)
124
+ .onChanged { value in
125
+ let phase = magnifying ? "changed" : "began"
126
+ magnifying = true
127
+ sendPinch(phase: phase, scale: value.magnification, rotation: 0, scaleVelocity: value.velocity, rotationVelocity: 0, startLocation: value.startLocation, startAnchor: value.startAnchor)
128
+ }
129
+ .onEnded { value in
130
+ magnifying = false
131
+ sendPinch(phase: "ended", scale: value.magnification, rotation: 0, scaleVelocity: value.velocity, rotationVelocity: 0, startLocation: value.startLocation, startAnchor: value.startAnchor)
101
132
  }
102
133
  ))
103
- : AnyView(content)
104
- if props.longPress {
105
- dragged.onLongPressGesture(minimumDuration: props.minimumDuration) {
106
- app?.dispatch(.longPress(node.id))
107
- }
134
+ : tracked
135
+ if props.rotate {
136
+ magnified.simultaneousGesture(
137
+ RotateGesture(minimumAngleDelta: .degrees(props.minimumAngleDelta))
138
+ .onChanged { value in
139
+ let phase = rotating ? "changed" : "began"
140
+ rotating = true
141
+ sendPinch(phase: phase, scale: 1, rotation: value.rotation.degrees, scaleVelocity: 0, rotationVelocity: value.velocity.degrees, startLocation: value.startLocation, startAnchor: value.startAnchor)
142
+ }
143
+ .onEnded { value in
144
+ rotating = false
145
+ sendPinch(phase: "ended", scale: 1, rotation: value.rotation.degrees, scaleVelocity: 0, rotationVelocity: value.velocity.degrees, startLocation: value.startLocation, startAnchor: value.startAnchor)
146
+ }
147
+ )
108
148
  } else {
109
- dragged
149
+ magnified
150
+ }
151
+ }
152
+
153
+ /// The gesture on the view as `mode` composes it with the view's others and its subviews'.
154
+ private func attach<G: Gesture>(_ gesture: G, to view: AnyView, mode: String?) -> AnyView {
155
+ switch mode {
156
+ case "simultaneous": AnyView(view.simultaneousGesture(gesture))
157
+ case "highPriority": AnyView(view.highPriorityGesture(gesture))
158
+ default: AnyView(view.gesture(gesture))
110
159
  }
111
160
  }
112
161
 
162
+ private func sendPinch(phase: String, scale: CGFloat, rotation: Double, scaleVelocity: CGFloat, rotationVelocity: Double, startLocation: CGPoint, startAnchor: UnitPoint) {
163
+ let pinch = Fingers.Pinch(scale: scale, rotation: rotation, scaleVelocity: scaleVelocity, rotationVelocity: rotationVelocity, startLocation: startLocation, startAnchor: startAnchor)
164
+ fingers.pinch = phase == "ended" ? nil : pinch
165
+ sendPinch(pinch, phase: phase)
166
+ }
167
+
168
+ private func sendPinch(_ pinch: Fingers.Pinch, phase: String) {
169
+ app?.dispatch(.pinch(
170
+ node.id, phase: phase, scale: pinch.scale, rotation: pinch.rotation, scaleVelocity: pinch.scaleVelocity, rotationVelocity: pinch.rotationVelocity,
171
+ startLocation: (pinch.startLocation.x, pinch.startLocation.y), startAnchor: (pinch.startAnchor.x, pinch.startAnchor.y),
172
+ location: fingers.midpoint.map { ($0.x, $0.y) }
173
+ ))
174
+ }
175
+
176
+ /// The fingers on the element, for the pinch's `location`: SwiftUI's
177
+ /// `MagnifyGesture` and `RotateGesture` tell where the fingers began, not
178
+ /// where they are. A move of the fingers alone (two fingers panning)
179
+ /// sends the pinch again with the new midpoint. Before iOS 18 there is no
180
+ /// `SpatialEventGesture`, and the pinch goes without a location.
181
+ private func trackingFingers(_ view: AnyView) -> AnyView {
182
+ // Apple's SwiftUI only: the shim the package also builds against has no spatial events.
183
+ #if os(iOS) || os(macOS)
184
+ guard #available(iOS 18.0, macOS 15.0, *) else { return view }
185
+ let fingers = fingers
186
+ return AnyView(view.simultaneousGesture(
187
+ SpatialEventGesture()
188
+ .onChanged { events in
189
+ let down = events.filter { $0.phase == .active }.map(\.location)
190
+ guard down.count >= 2 else { return }
191
+ let midpoint = CGPoint(x: down.map(\.x).reduce(0, +) / CGFloat(down.count), y: down.map(\.y).reduce(0, +) / CGFloat(down.count))
192
+ guard midpoint != fingers.midpoint else { return }
193
+ fingers.midpoint = midpoint
194
+ if let pinch = fingers.pinch { sendPinch(pinch, phase: "changed") }
195
+ }
196
+ .onEnded { _ in fingers.midpoint = nil }
197
+ ))
198
+ #else
199
+ return view
200
+ #endif
201
+ }
202
+
113
203
  private func send(_ value: DragGesture.Value, phase: String) {
114
204
  app?.dispatch(.drag(
115
205
  node.id, phase: phase,
@@ -121,6 +211,21 @@ struct GestureElement: View {
121
211
  }
122
212
  }
123
213
 
214
+ /// A pinch element's fingers: their midpoint while two are down, and the
215
+ /// pinch they drive. A reference, so a move of the fingers does not redraw.
216
+ final class Fingers {
217
+ struct Pinch {
218
+ var scale: CGFloat
219
+ var rotation: Double
220
+ var scaleVelocity: CGFloat
221
+ var rotationVelocity: Double
222
+ var startLocation: CGPoint
223
+ var startAnchor: UnitPoint
224
+ }
225
+ var midpoint: CGPoint?
226
+ var pinch: Pinch?
227
+ }
228
+
124
229
  /// `viewthatfits`: the children are the candidates, in order.
125
230
  struct ViewThatFitsElement: View {
126
231
  let node: ElementNode
@@ -109,12 +109,21 @@ struct NavigationScreenElement: View {
109
109
 
110
110
  var body: some View {
111
111
  let props = NavScreenProps(props: node.props)
112
- let toolbars = node.children.filter { $0.kind == "toolbar" }
112
+ let allToolbars = node.children.filter { $0.kind == "toolbar" }
113
+ // On iOS the `keyboard` toolbars are a bar of the content's (KeyboardBar.swift), not SwiftUI's placement.
114
+ #if os(iOS) && !canImport(FoundationBridge)
115
+ let keyboardToolbars = allToolbars.filter { ToolbarProps(props: $0.props).placement == "keyboard" }
116
+ let toolbars = allToolbars.filter { ToolbarProps(props: $0.props).placement != "keyboard" }
117
+ #else
118
+ let keyboardToolbars: [ElementNode] = []
119
+ let toolbars = allToolbars
120
+ #endif
113
121
  let search = node.children.first { $0.kind == "searchfield" }
114
122
  let content = ZStack {
115
123
  ForEach(node.children(except: ["toolbar", "searchfield"])) { ElementView(node: $0) }
116
124
  }
117
125
  let titled = content
126
+ .modifier(KeyboardBarInset(toolbars: keyboardToolbars))
118
127
  .navigationTitle(props.title)
119
128
  .toolbar { ScreenToolbar(toolbars: toolbars) }
120
129
  let searched: AnyView = {
@@ -155,6 +164,8 @@ struct NavigationScreenElement: View {
155
164
  case "principal": return .principal
156
165
  case "secondary": return .secondaryAction
157
166
  case "bottom": return bottomPlacement
167
+ // The bar above the keyboard while a text input of the screen is focused.
168
+ case "keyboard": return .keyboard
158
169
  case "largeTitle":
159
170
  // In the large title's place: the shim's placement, and SwiftUI's
160
171
  // from iOS 26 (it stands in for `navigationTitle`'s text while the
@@ -29,7 +29,14 @@ struct ShapeElement: View {
29
29
  shape.fill(fill)
30
30
  }
31
31
  if let stroke = props.stroke {
32
- let style = StrokeStyle(lineWidth: stroke.lineWidth, dash: stroke.dash.map { CGFloat($0) })
32
+ let style = StrokeStyle(
33
+ lineWidth: stroke.lineWidth,
34
+ lineCap: stroke.lineCap == "round" ? .round : stroke.lineCap == "square" ? .square : .butt,
35
+ lineJoin: stroke.lineJoin == "round" ? .round : stroke.lineJoin == "bevel" ? .bevel : .miter,
36
+ miterLimit: stroke.miterLimit,
37
+ dash: stroke.dash.map { CGFloat($0) },
38
+ dashPhase: stroke.dashPhase
39
+ )
33
40
  let paint = stroke.style.map(AnyShapeStyle.init(spec:)) ?? AnyShapeStyle(ForegroundStyle())
34
41
  if stroke.inset, props.trimFrom == nil, let insettable = props.insettableShape {
35
42
  strokeBorder(insettable, paint, style)
@@ -72,23 +79,37 @@ extension AnyShape {
72
79
  case "ellipse": self = AnyShape(Ellipse())
73
80
  case "roundedRectangle", "containerRelative": self = AnyShape(RoundedRectangle(cornerRadius: spec.cornerRadius, style: .continuous))
74
81
  case "path":
75
- #if canImport(FoundationBridge)
76
- // The shim's `Path` does not parse SVG path data yet: nothing to draw.
77
- self = AnyShape(NoPathShape())
78
- #else
79
- if let path = spec.path, !path.isEmpty, let parsed = Path(path) {
82
+ if let data = spec.path, !data.isEmpty, let parsed = Path(svg: data) {
80
83
  self = AnyShape(PathShape(path: parsed, size: spec.size.map { CGSize(width: $0.width, height: $0.height) }))
81
84
  } else {
82
- // No path yet (the size is not reported): nothing to draw.
85
+ // No path yet (the size is not reported), or none to read: nothing to draw.
83
86
  self = AnyShape(NoPathShape())
84
87
  }
85
- #endif
86
88
  default: self = AnyShape(Rectangle())
87
89
  }
88
90
  }
89
91
  }
90
92
 
91
- /// A custom shape from SVG path data (`Path(_:)` parses it). The app drew it
93
+ extension Path {
94
+ /// A path from SVG path data (`SVGPath`): what the protocol carries. SwiftUI's
95
+ /// own `Path(_:)` reads only `Path.description`'s form, so it read none of it.
96
+ init?(svg data: String) {
97
+ guard let elements = SVGPath.parse(data) else { return nil }
98
+ func point(_ p: SVGPath.Point) -> CGPoint { CGPoint(x: p.x, y: p.y) }
99
+ self.init()
100
+ for element in elements {
101
+ switch element {
102
+ case .move(let to): move(to: point(to))
103
+ case .line(let to): addLine(to: point(to))
104
+ case .quadCurve(let to, let control): addQuadCurve(to: point(to), control: point(control))
105
+ case .curve(let to, let control1, let control2): addCurve(to: point(to), control1: point(control1), control2: point(control2))
106
+ case .closeSubpath: closeSubpath()
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ /// A custom shape from SVG path data (`Path(svg:)` reads it). The app drew it
92
113
  /// for the size the element reported (`size`); until the next report it is
93
114
  /// stretched to the box, as the web renderer stretches its view box.
94
115
  /// A shape with no path, for a custom shape before its first size report.
@@ -4,17 +4,33 @@ import SwiftBrowserProtocol
4
4
  import SwiftBrowserTree
5
5
  import SwiftUI
6
6
 
7
+ /// `vstack`; one with `pinned` is a `LazyVStack(pinnedViews:)`, whose `Section` children pin their headers.
7
8
  struct VStackElement: View {
8
9
  let node: ElementNode
9
10
  var body: some View {
10
11
  let props = StackProps(props: node.props)
11
- VStack(alignment: HorizontalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
12
- ChildrenView(node: node)
12
+ if let pinned = pinnedViews(props.pinned) {
13
+ LazyVStack(alignment: HorizontalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }, pinnedViews: pinned) {
14
+ ChildrenView(node: node)
15
+ }
16
+ .scrollTargetLayout(when: props.scrollTarget)
17
+ } else {
18
+ VStack(alignment: HorizontalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
19
+ ChildrenView(node: node)
20
+ }
21
+ .scrollTargetLayout(when: props.scrollTarget)
13
22
  }
14
- .scrollTargetLayout(when: props.scrollTarget)
15
23
  }
16
24
  }
17
25
 
26
+ /// `pinnedViews` from the protocol's names (`sectionHeaders`, `sectionFooters`); nil when none.
27
+ func pinnedViews(_ names: [String]) -> PinnedScrollableViews? {
28
+ var views: PinnedScrollableViews = []
29
+ if names.contains("sectionHeaders") { views.insert(.sectionHeaders) }
30
+ if names.contains("sectionFooters") { views.insert(.sectionFooters) }
31
+ return views.isEmpty ? nil : views
32
+ }
33
+
18
34
  /// `hstack`; a `Label` in its default style (the marked icon, `labelIcon` on
19
35
  /// its `styled` wrapper, then the title) is a `Label`, so iOS lays it out and
20
36
  /// colors it as one: the icon column and tint in a list row, the inherited
@@ -29,6 +45,11 @@ struct HStackElement: View {
29
45
  } icon: {
30
46
  ElementView(node: icon)
31
47
  }
48
+ } else if let pinned = pinnedViews(props.pinned) {
49
+ LazyHStack(alignment: VerticalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }, pinnedViews: pinned) {
50
+ ChildrenView(node: node)
51
+ }
52
+ .scrollTargetLayout(when: props.scrollTarget)
32
53
  } else {
33
54
  HStack(alignment: VerticalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
34
55
  ChildrenView(node: node)
@@ -97,7 +118,15 @@ struct GeometryElement: View {
97
118
  let props = GeometryProps(props: node.props)
98
119
  GeometryReader { proxy in
99
120
  let frame = proxy.frame(in: .global)
100
- ChildrenView(node: node)
121
+ // The report hangs off a clear view filling the reader, not off the
122
+ // children: a reader whose content draws nothing (a component that
123
+ // renders null) has no view for a modifier to run on, and never
124
+ // reported. The children sit at its top leading corner, as a
125
+ // reader's content does.
126
+ ZStack(alignment: .topLeading) {
127
+ Color.clear
128
+ ChildrenView(node: node)
129
+ }
101
130
  .onChange(of: frame, initial: true) { _, frame in
102
131
  report(frame, reportsFrame: props.reportsFrame)
103
132
  }
@@ -132,6 +161,13 @@ struct GeometryElement: View {
132
161
  /// time, on the marked element it names (`scrollID`, `.id(x)`): the view
133
162
  /// finds the latest in its content and scrolls to it.
134
163
  ///
164
+ /// `reportsGeometry` (`.onScrollGeometryChange`, iOS 18) sends
165
+ /// `scrollgeometry` as the view scrolls and lays out; `scrollOffset`
166
+ /// (`ScrollPosition.scrollTo(x:y:)`, iOS 18) scrolls to a content offset
167
+ /// once per serial. A view with an offset command is positioned by a
168
+ /// `ScrollPosition` of its own and does not report ids (`reportsPosition`),
169
+ /// since SwiftUI takes one position per scroll view.
170
+ ///
135
171
  /// On iOS 18 and later the position is what the view settled on: the
136
172
  /// visible target at the anchor once the scroll phase is idle, after a drag
137
173
  /// or a `scrollTo` alike (so the app hears where a chevron's scroll ended),
@@ -148,6 +184,8 @@ struct ScrollViewElement: View {
148
184
  @State private var idle = true
149
185
  @State private var reported: Int?
150
186
  @State private var appeared = false
187
+ /// The `scrollOffset` serial last carried out.
188
+ @State private var offsetServed: Double = -1
151
189
 
152
190
  var body: some View {
153
191
  let props = ScrollViewProps(props: node.props)
@@ -166,10 +204,13 @@ struct ScrollViewElement: View {
166
204
  let scroll = ScrollView(axes, showsIndicators: props.showsIndicators) {
167
205
  ChildrenView(node: node)
168
206
  }
169
- let refreshed: AnyView = props.refreshable ? AnyView(scroll.refreshable { app?.dispatch(.refresh(node.id)) }) : AnyView(scroll)
207
+ let anchored = anchored(AnyView(scroll), props: props)
208
+ let refreshed: AnyView = props.refreshable ? AnyView(anchored.refreshable { app?.dispatch(.refresh(node.id)) }) : anchored
170
209
  let snapped: AnyView = props.snap == "paging" ? AnyView(refreshed.scrollTargetBehavior(.paging))
171
210
  : props.snap == "viewAligned" ? AnyView(refreshed.scrollTargetBehavior(.viewAligned)) : refreshed
172
- let positioned: AnyView = props.reportsPosition
211
+ let positioned: AnyView = props.scrollOffset != nil
212
+ ? offsetPositioned(snapped, props: props)
213
+ : props.reportsPosition
173
214
  ? reportingSettled(AnyView(snapped.scrollPosition(id: Binding(get: { ahead ?? position }, set: { next in
174
215
  guard next != position else { return }
175
216
  position = next
@@ -177,7 +218,7 @@ struct ScrollViewElement: View {
177
218
  if !Self.reportsSettled { report(next) }
178
219
  }), anchor: anchor)), anchor: anchor)
179
220
  : snapped
180
- positioned
221
+ reportingGeometry(positioned, when: props.reportsGeometry)
181
222
  .onAppear {
182
223
  perform(request, props: props, with: proxy)
183
224
  appeared = true
@@ -186,6 +227,22 @@ struct ScrollViewElement: View {
186
227
  }
187
228
  }
188
229
 
230
+ /// `defaultScrollAnchor`: the app's anchor, with all SwiftUI makes of it
231
+ /// (where the content starts, where it stays as it grows, and where
232
+ /// content smaller than the view sits). Without one, a view that scrolls
233
+ /// both ways keeps smaller content at its origin, as the page lays it out
234
+ /// (docs/ops-protocol.md, the `scrollview` layout row): SwiftUI centers it
235
+ /// along the vertical axis and reports the centering as a negative offset,
236
+ /// which put a short sheet in Numbers 92 points below its frozen header.
237
+ private func anchored(_ view: AnyView, props: ScrollViewProps) -> AnyView {
238
+ if let anchor = props.defaultScrollAnchor { return AnyView(view.defaultScrollAnchor(UnitPoint(spec: anchor))) }
239
+ guard props.axes == "both" else { return view }
240
+ #if !canImport(FoundationBridge)
241
+ if #available(iOS 18, macOS 15, *) { return AnyView(view.defaultScrollAnchor(.topLeading, for: .alignment)) }
242
+ #endif
243
+ return view
244
+ }
245
+
189
246
  private func perform(_ request: ScrollRequest?, props: ScrollViewProps, with proxy: ScrollViewProxy) {
190
247
  guard let request, request.serial > served else { return }
191
248
  served = request.serial
@@ -274,6 +331,43 @@ struct ScrollViewElement: View {
274
331
  return view
275
332
  }
276
333
 
334
+ /// `.onScrollGeometryChange` (iOS 18): the offset, the content's and the
335
+ /// view's sizes and the insets, sent in the action itself. The app
336
+ /// answers synchronously with a pass, and what that pass moves (Numbers'
337
+ /// frozen headers, offset by the scroll) SwiftUI draws in the update the
338
+ /// scroll is drawn in, as a `@State` set in the action is: the frame the
339
+ /// web renderer gets by sending the geometry in the animation frame of
340
+ /// the scroll, before it paints. Deferred a turn (as GeometryElement's
341
+ /// reports are, measured during layout), the headers trailed the cells
342
+ /// by a frame.
343
+ private func reportingGeometry(_ view: AnyView, when reports: Bool) -> AnyView {
344
+ guard reports else { return view }
345
+ #if !canImport(FoundationBridge)
346
+ if #available(iOS 18, macOS 15, *) {
347
+ let app = app
348
+ let id = node.id
349
+ return AnyView(view.onScrollGeometryChange(for: ScrollGeometryRecord.self) { geometry in
350
+ ScrollGeometryRecord(geometry)
351
+ } action: { _, record in
352
+ app?.dispatch(record.event(id))
353
+ })
354
+ }
355
+ #endif
356
+ return view
357
+ }
358
+
359
+ /// `scrollOffset` (iOS 18): a `ScrollPosition` of the view's own, told to
360
+ /// scroll to the command's point once per serial, from the view's
361
+ /// appearance on (the first pass has no size to scroll in).
362
+ private func offsetPositioned(_ view: AnyView, props: ScrollViewProps) -> AnyView {
363
+ #if !canImport(FoundationBridge)
364
+ if #available(iOS 18, macOS 15, *) {
365
+ return AnyView(OffsetPositioned(content: view, command: props.scrollOffset, served: $offsetServed))
366
+ }
367
+ #endif
368
+ return view
369
+ }
370
+
277
371
  /// The target an anchor picks among the visible ones, in order: the first for a leading anchor (and none), the last for a trailing one, else the middle.
278
372
  static func target(at anchor: UnitPoint?, among ids: [Int]) -> Int? {
279
373
  guard !ids.isEmpty else { return nil }
@@ -322,14 +416,15 @@ struct ScrollViewElement: View {
322
416
  }
323
417
  }
324
418
 
325
- /// `LazyVGrid`: `columns` are `GridItem`s; children are the cells.
419
+ /// `LazyVGrid`: `columns` are `GridItem`s; children are the cells; `pinned` its `pinnedViews`.
326
420
  struct LazyVGridElement: View {
327
421
  let node: ElementNode
328
422
  var body: some View {
329
423
  LazyVGrid(
330
424
  columns: gridItems(node.props.array("columns") ?? []),
331
425
  alignment: HorizontalAlignment(name: node.props.string("alignment") ?? "center"),
332
- spacing: node.props.double("spacing").map { CGFloat($0) }
426
+ spacing: node.props.double("spacing").map { CGFloat($0) },
427
+ pinnedViews: pinnedViews(node.props.array("pinned")?.compactMap(\.string) ?? []) ?? []
333
428
  ) {
334
429
  ChildrenView(node: node)
335
430
  }
@@ -342,7 +437,8 @@ struct LazyHGridElement: View {
342
437
  LazyHGrid(
343
438
  rows: gridItems(node.props.array("rows") ?? []),
344
439
  alignment: VerticalAlignment(name: node.props.string("alignment") ?? "center"),
345
- spacing: node.props.double("spacing").map { CGFloat($0) }
440
+ spacing: node.props.double("spacing").map { CGFloat($0) },
441
+ pinnedViews: pinnedViews(node.props.array("pinned")?.compactMap(\.string) ?? []) ?? []
346
442
  ) {
347
443
  ChildrenView(node: node)
348
444
  }
@@ -384,4 +480,61 @@ struct GridRowElement: View {
384
480
  }
385
481
  }
386
482
  }
483
+ #if !canImport(FoundationBridge)
484
+ /// A `ScrollGeometry` as the protocol sends it; `Equatable` so the action hears real changes only.
485
+ @available(iOS 18, macOS 15, *)
486
+ struct ScrollGeometryRecord: Equatable {
487
+ var offset: CGPoint
488
+ var contentSize: CGSize
489
+ var containerSize: CGSize
490
+ var insets: EdgeInsets
491
+
492
+ init(_ geometry: ScrollGeometry) {
493
+ offset = geometry.contentOffset
494
+ contentSize = geometry.contentSize
495
+ containerSize = geometry.containerSize
496
+ insets = geometry.contentInsets
497
+ }
498
+
499
+ func event(_ id: Int) -> RendererEvent {
500
+ .scrollGeometry(
501
+ id, offset: (offset.x, offset.y), contentSize: (contentSize.width, contentSize.height),
502
+ containerSize: (containerSize.width, containerSize.height),
503
+ contentInsets: (insets.top, insets.leading, insets.bottom, insets.trailing)
504
+ )
505
+ }
506
+ }
507
+
508
+ /// A scroll view positioned by a `ScrollPosition`, scrolled to each new `scrollOffset` command.
509
+ @available(iOS 18, macOS 15, *)
510
+ struct OffsetPositioned: View {
511
+ let content: AnyView
512
+ let command: ScrollOffsetSpec?
513
+ @Binding var served: Double
514
+ @State private var position = ScrollPosition(edge: .top)
515
+ @State private var appeared = false
516
+
517
+ var body: some View {
518
+ content
519
+ .scrollPosition($position)
520
+ .onAppear {
521
+ appeared = true
522
+ perform()
523
+ }
524
+ .onChange(of: command?.serial) { _, _ in perform() }
525
+ }
526
+
527
+ private func perform() {
528
+ guard appeared, let command, command.serial > served else { return }
529
+ served = command.serial
530
+ // `scrollTo` takes the axes it is given; an absent one keeps its offset.
531
+ switch (command.x.map { CGFloat($0) }, command.y.map { CGFloat($0) }) {
532
+ case let (x?, y?): position.scrollTo(x: x, y: y)
533
+ case let (x?, nil): position.scrollTo(x: x)
534
+ case let (nil, y?): position.scrollTo(y: y)
535
+ case (nil, nil): break
536
+ }
537
+ }
538
+ }
539
+ #endif
387
540
  #endif
@@ -40,15 +40,28 @@ struct TextElement: View {
40
40
  case .asset(let name, _): text = Text(Image(name))
41
41
  default: text = Text(verbatim: "")
42
42
  }
43
- } else if let link = run.link, let url = URL(string: link) {
44
- // Markdown gives a tappable link run; the text is escaped for it.
43
+ } else if run.link != nil || run.background != nil {
44
+ // A link run is tappable, a background run colored behind its
45
+ // glyphs: both are attributes of an `AttributedString`, which
46
+ // `Text` has and `Text`'s own modifiers do not.
45
47
  var string = AttributedString(run.text ?? "")
46
- string.link = url
48
+ if let link = run.link, let url = URL(string: link) { string.link = url }
49
+ if let background = run.background { string.backgroundColor = Color(spec: background) }
47
50
  text = Text(string)
48
51
  } else {
49
52
  text = Text(verbatim: run.text ?? "")
50
53
  }
51
- if let font = run.font { text = text.font(Font(spec: font)) }
54
+ if let font = run.font {
55
+ if font.size == nil, font.textStyle == nil {
56
+ // Only a weight or design (a bold run): over the text's own
57
+ // font, as `StyleModifier` does, not the body font in it.
58
+ if let weight = font.weight { text = text.fontWeight(Font.Weight(name: weight)) }
59
+ if let design = font.design { text = text.fontDesign(Font.Design(name: design)) }
60
+ if font.monospacedDigit { text = text.monospacedDigit() }
61
+ } else {
62
+ text = text.font(Font(spec: font))
63
+ }
64
+ }
52
65
  if run.italic { text = text.italic() }
53
66
  if run.underline { text = text.underline(true, color: run.underlineColor.map(Color.init(spec:))) }
54
67
  if run.strikethrough { text = text.strikethrough(true, color: run.strikethroughColor.map(Color.init(spec:))) }