@swiftbrowser/native 0.11.0 → 0.13.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 (46) hide show
  1. package/README.md +38 -1
  2. package/bin/swiftbrowser-native.mjs +27 -8
  3. package/dist/bridge.d.ts +7 -0
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js.map +1 -1
  6. package/dist/build.d.ts +22 -2
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +25 -8
  9. package/dist/build.js.map +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/runtime.d.ts +7 -3
  15. package/dist/runtime.d.ts.map +1 -1
  16. package/dist/runtime.js +16 -3
  17. package/dist/runtime.js.map +1 -1
  18. package/dist/site.d.ts +8 -0
  19. package/dist/site.d.ts.map +1 -1
  20. package/dist/site.js +24 -5
  21. package/dist/site.js.map +1 -1
  22. package/dist/worker.d.ts +87 -0
  23. package/dist/worker.d.ts.map +1 -0
  24. package/dist/worker.js +213 -0
  25. package/dist/worker.js.map +1 -0
  26. package/package.json +3 -3
  27. package/src/bridge.ts +12 -0
  28. package/src/build.ts +40 -8
  29. package/src/index.ts +1 -0
  30. package/src/runtime.ts +16 -3
  31. package/src/site.ts +31 -5
  32. package/src/worker.ts +236 -0
  33. package/swift/Package.swift +1 -0
  34. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +46 -2
  35. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +2 -1
  36. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +279 -0
  37. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +8 -1
  38. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Charts.swift +340 -0
  39. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Inspector.swift +29 -0
  40. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +8 -1
  41. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +58 -6
  42. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +25 -7
  43. package/swift/Sources/SwiftBrowserProtocol/Props.swift +170 -0
  44. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +2 -0
  45. package/swift/Sources/SwiftBrowserProtocol/Style.swift +41 -0
  46. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -0,0 +1,340 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+
7
+ /// `chart`: Swift Charts as the protocol carries it, already resolved (the
8
+ /// marks in unit coordinates of the plot, the axes' grid lines, ticks and
9
+ /// formatted labels, the legend), drawn with paths and texts, following the
10
+ /// web renderer's layout rules (docs/ops-protocol.md, "Layout rules: chart"):
11
+ /// the legend row, the axis bands as wide as their labels, and the plot in
12
+ /// what remains. `chartitem` children are annotations against their mark's
13
+ /// rect, or axis label views at their tick.
14
+ struct ChartElement: View {
15
+ let node: ElementNode
16
+
17
+ var body: some View {
18
+ let props = ChartProps(props: node.props)
19
+ let items = node.children.filter { $0.kind == "chartitem" }
20
+ GeometryReader { geometry in
21
+ ChartCanvas(props: props, items: items, size: geometry.size)
22
+ }
23
+ }
24
+ }
25
+
26
+ /// The chart's boxes in points.
27
+ struct ChartLayout {
28
+ var plot: CGRect
29
+ var xBand: CGRect?
30
+ var yBand: CGRect?
31
+ var legend: CGRect?
32
+
33
+ static let axisFontSize: CGFloat = 12
34
+ /// A caption glyph's average width, for the y band's width (the labels are not measured).
35
+ static let glyphWidth: CGFloat = 6.6
36
+ static let labelGap: CGFloat = 6
37
+ static let legendHeight: CGFloat = 16
38
+ static let legendGap: CGFloat = 8
39
+
40
+ init(props: ChartProps, size: CGSize) {
41
+ var plot = CGRect(origin: .zero, size: size)
42
+ if let legend = props.legend, !legend.items.isEmpty {
43
+ switch legend.position {
44
+ case "top":
45
+ self.legend = CGRect(x: 0, y: 0, width: size.width, height: Self.legendHeight)
46
+ plot.origin.y += Self.legendHeight + Self.legendGap
47
+ plot.size.height -= Self.legendHeight + Self.legendGap
48
+ case "leading", "trailing":
49
+ let width = legend.items.map { CGFloat($0.label.count) * Self.glyphWidth + 14 }.max() ?? 0
50
+ if legend.position == "leading" {
51
+ self.legend = CGRect(x: 0, y: 0, width: width, height: size.height)
52
+ plot.origin.x += width + Self.legendGap
53
+ } else {
54
+ self.legend = CGRect(x: size.width - width, y: 0, width: width, height: size.height)
55
+ }
56
+ plot.size.width -= width + Self.legendGap
57
+ default:
58
+ self.legend = CGRect(x: 0, y: size.height - Self.legendHeight, width: size.width, height: Self.legendHeight)
59
+ plot.size.height -= Self.legendHeight + Self.legendGap
60
+ }
61
+ }
62
+ if let axis = props.yAxis, !axis.labels.isEmpty {
63
+ let width = (axis.labels.map { CGFloat($0.text.count) }.max() ?? 0) * Self.glyphWidth + Self.labelGap
64
+ if axis.position == "leading" {
65
+ yBand = CGRect(x: plot.minX, y: plot.minY, width: width, height: plot.height)
66
+ plot.origin.x += width
67
+ } else {
68
+ yBand = CGRect(x: plot.maxX - width, y: plot.minY, width: width, height: plot.height)
69
+ }
70
+ plot.size.width -= width
71
+ }
72
+ if let axis = props.xAxis, !axis.labels.isEmpty {
73
+ let height = Self.axisFontSize * 4 / 3 + Self.labelGap
74
+ if axis.position == "top" {
75
+ xBand = CGRect(x: plot.minX, y: plot.minY, width: plot.width, height: height)
76
+ plot.origin.y += height
77
+ } else {
78
+ xBand = CGRect(x: plot.minX, y: plot.maxY - height, width: plot.width, height: height)
79
+ }
80
+ plot.size.height -= height
81
+ }
82
+ self.plot = CGRect(x: plot.minX, y: plot.minY, width: max(plot.width, 0), height: max(plot.height, 0))
83
+ }
84
+
85
+ func point(x: Double, y: Double) -> CGPoint {
86
+ CGPoint(x: plot.minX + plot.width * x, y: plot.minY + plot.height * y)
87
+ }
88
+
89
+ func rect(x0: Double, x1: Double, y0: Double, y1: Double) -> CGRect {
90
+ let a = point(x: min(x0, x1), y: min(y0, y1)), b = point(x: max(x0, x1), y: max(y0, y1))
91
+ return CGRect(x: a.x, y: a.y, width: b.x - a.x, height: b.y - a.y)
92
+ }
93
+ }
94
+
95
+ struct ChartCanvas: View {
96
+ let props: ChartProps
97
+ let items: [ElementNode]
98
+ let size: CGSize
99
+
100
+ var body: some View {
101
+ let layout = ChartLayout(props: props, size: size)
102
+ ZStack(alignment: .topLeading) {
103
+ axisLines(props.xAxis, layout: layout, horizontal: true)
104
+ axisLines(props.yAxis, layout: layout, horizontal: false)
105
+ ForEach(Array(props.marks.enumerated()), id: \.offset) { _, mark in
106
+ ChartMarkView(mark: mark, layout: layout)
107
+ }
108
+ axisLabels(props.xAxis, layout: layout, horizontal: true)
109
+ axisLabels(props.yAxis, layout: layout, horizontal: false)
110
+ if let legend = props.legend, let box = layout.legend {
111
+ ChartLegendView(legend: legend)
112
+ .frame(width: box.width, height: box.height)
113
+ .position(x: box.midX, y: box.midY)
114
+ }
115
+ ForEach(items) { item in
116
+ ChartItemView(item: item, layout: layout)
117
+ }
118
+ }
119
+ .frame(width: size.width, height: size.height, alignment: .topLeading)
120
+ .clipped()
121
+ }
122
+
123
+ /// The grid lines across the plot and the ticks outside it.
124
+ @ViewBuilder
125
+ private func axisLines(_ axis: ChartAxisSpec?, layout: ChartLayout, horizontal: Bool) -> some View {
126
+ if let axis {
127
+ let plot = layout.plot
128
+ let grid = Path { path in
129
+ for line in axis.gridLines {
130
+ if horizontal {
131
+ let x = plot.minX + plot.width * line
132
+ path.move(to: CGPoint(x: x, y: plot.minY))
133
+ path.addLine(to: CGPoint(x: x, y: plot.maxY))
134
+ } else {
135
+ let y = plot.minY + plot.height * line
136
+ path.move(to: CGPoint(x: plot.minX, y: y))
137
+ path.addLine(to: CGPoint(x: plot.maxX, y: y))
138
+ }
139
+ }
140
+ }
141
+ let ticks = Path { path in
142
+ for tick in axis.ticks {
143
+ if horizontal {
144
+ let x = plot.minX + plot.width * tick
145
+ let y = axis.position == "top" ? plot.minY : plot.maxY
146
+ path.move(to: CGPoint(x: x, y: y))
147
+ path.addLine(to: CGPoint(x: x, y: axis.position == "top" ? y - 4 : y + 4))
148
+ } else {
149
+ let y = plot.minY + plot.height * tick
150
+ let x = axis.position == "leading" ? plot.minX : plot.maxX
151
+ path.move(to: CGPoint(x: x, y: y))
152
+ path.addLine(to: CGPoint(x: axis.position == "leading" ? x - 4 : x + 4, y: y))
153
+ }
154
+ }
155
+ }
156
+ let separator = Color.secondary.opacity(0.3)
157
+ grid.stroke(separator, style: StrokeStyle(lineWidth: 1, dash: axis.dashed ? [2, 2] : []))
158
+ ticks.stroke(separator, lineWidth: 1)
159
+ }
160
+ }
161
+
162
+ /// The default text labels, centered on their ticks in the band.
163
+ @ViewBuilder
164
+ private func axisLabels(_ axis: ChartAxisSpec?, layout: ChartLayout, horizontal: Bool) -> some View {
165
+ if let axis {
166
+ ForEach(Array(axis.labels.enumerated()), id: \.offset) { _, label in
167
+ let text = Text(verbatim: label.text)
168
+ .font(.system(size: ChartLayout.axisFontSize))
169
+ .foregroundStyle(.secondary)
170
+ .lineLimit(1)
171
+ .fixedSize()
172
+ if horizontal, let band = layout.xBand {
173
+ let x = layout.plot.minX + layout.plot.width * label.at
174
+ if label.anchor == "leading" {
175
+ text.frame(width: max(band.width, 1), alignment: .leading).position(x: x + band.width / 2, y: band.midY)
176
+ } else {
177
+ text.position(x: min(max(x, layout.plot.minX), layout.plot.maxX), y: band.midY)
178
+ }
179
+ } else if !horizontal, let band = layout.yBand {
180
+ let y = layout.plot.minY + layout.plot.height * label.at
181
+ text.frame(width: max(band.width - ChartLayout.labelGap, 1), alignment: axis.position == "leading" ? .trailing : .leading)
182
+ .position(x: band.midX + (axis.position == "leading" ? -ChartLayout.labelGap / 2 : ChartLayout.labelGap / 2), y: min(max(y, layout.plot.minY), layout.plot.maxY))
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ /// One mark, in the plot's points.
190
+ struct ChartMarkView: View {
191
+ let mark: ChartMarkSpec
192
+ let layout: ChartLayout
193
+
194
+ var body: some View {
195
+ let style = mark.style.map(AnyShapeStyle.init(spec:)) ?? AnyShapeStyle(Color.accentColor)
196
+ Group {
197
+ switch mark.kind {
198
+ case "rect":
199
+ let unit = layout.rect(x0: mark.x0, x1: mark.x1, y0: mark.y0, y1: mark.y1)
200
+ let width = mark.fixedWidth.map { CGFloat($0) } ?? unit.width
201
+ RoundedRectangle(cornerRadius: mark.cornerRadius, style: .continuous)
202
+ .fill(style)
203
+ .frame(width: max(width, 0), height: max(unit.height, 0))
204
+ .position(x: unit.midX, y: unit.midY)
205
+ case "line":
206
+ let points = mark.points.map { layout.point(x: $0.x, y: $0.y) }
207
+ Path { path in
208
+ if let first = points.first {
209
+ path.move(to: first)
210
+ path.addLines(Array(points.dropFirst()))
211
+ }
212
+ }
213
+ .stroke(style, style: StrokeStyle(lineWidth: mark.lineWidth, lineCap: .round, lineJoin: .round))
214
+ if let symbol = mark.symbol {
215
+ ForEach(Array(points.enumerated()), id: \.offset) { _, point in
216
+ ChartSymbolView(symbol: symbol, size: mark.symbolSize, style: style).position(point)
217
+ }
218
+ }
219
+ case "area":
220
+ let upper = mark.points.map { layout.point(x: $0.x, y: $0.y0) }
221
+ let lower = mark.points.reversed().map { layout.point(x: $0.x, y: $0.y1) }
222
+ Path { path in
223
+ if let first = upper.first {
224
+ path.move(to: first)
225
+ path.addLines(Array(upper.dropFirst()))
226
+ path.addLines(lower)
227
+ path.closeSubpath()
228
+ }
229
+ }
230
+ .fill(style)
231
+ case "point":
232
+ ChartSymbolView(symbol: mark.symbol ?? "circle", size: mark.symbolSize, style: style)
233
+ .position(layout.point(x: mark.x0, y: mark.y0))
234
+ case "rule":
235
+ Path { path in
236
+ path.move(to: layout.point(x: mark.x0, y: mark.y0))
237
+ path.addLine(to: layout.point(x: mark.x1, y: mark.y1))
238
+ }
239
+ .stroke(style, lineWidth: mark.lineWidth)
240
+ default:
241
+ EmptyView()
242
+ }
243
+ }
244
+ .opacity(mark.opacity)
245
+ }
246
+ }
247
+
248
+ /// A mark's symbol glyph: a circle, a square, or a diamond for the rest.
249
+ struct ChartSymbolView: View {
250
+ let symbol: String
251
+ let size: Double
252
+ let style: AnyShapeStyle
253
+
254
+ var body: some View {
255
+ switch symbol {
256
+ case "square":
257
+ Rectangle().fill(style).frame(width: size, height: size)
258
+ case "circle":
259
+ Circle().fill(style).frame(width: size, height: size)
260
+ default:
261
+ Rectangle().fill(style).frame(width: size * 0.8, height: size * 0.8).rotationEffect(.degrees(45))
262
+ }
263
+ }
264
+ }
265
+
266
+ /// The legend's items: a symbol (or a short line) and the label, flowing in a row.
267
+ struct ChartLegendView: View {
268
+ let legend: ChartLegendSpec
269
+
270
+ var body: some View {
271
+ let items = Array(legend.items.enumerated())
272
+ if legend.position == "leading" || legend.position == "trailing" {
273
+ VStack(alignment: .leading, spacing: 4) {
274
+ ForEach(items, id: \.offset) { _, item in row(item) }
275
+ }
276
+ } else {
277
+ HStack(spacing: 12) {
278
+ ForEach(items, id: \.offset) { _, item in row(item) }
279
+ }
280
+ }
281
+ }
282
+
283
+ private func row(_ item: ChartLegendItemSpec) -> some View {
284
+ let style = item.style.map(AnyShapeStyle.init(spec:)) ?? AnyShapeStyle(Color.accentColor)
285
+ return HStack(spacing: 4) {
286
+ if let symbol = item.symbol {
287
+ ChartSymbolView(symbol: symbol, size: 10, style: style)
288
+ } else {
289
+ Rectangle().fill(style).frame(width: 18, height: 3)
290
+ }
291
+ Text(verbatim: item.label).font(.system(size: ChartLayout.axisFontSize)).foregroundStyle(.secondary).lineLimit(1)
292
+ }
293
+ }
294
+ }
295
+
296
+ /// A `chartitem`: its one child against its mark's rect (an annotation) or
297
+ /// at its tick in the band (an axis label view). An annotation beside the
298
+ /// rect sits in a frame the rect's size, aligned to the edge it leaves from,
299
+ /// so it overflows outward by its own size plus the spacing.
300
+ struct ChartItemView: View {
301
+ let item: ElementNode
302
+ let layout: ChartLayout
303
+
304
+ var body: some View {
305
+ let props = ChartItemProps(props: item.props)
306
+ let content = ZStack { ChildrenView(node: item) }
307
+ switch props.role {
308
+ case "xLabel":
309
+ let band = layout.xBand ?? CGRect(x: layout.plot.minX, y: layout.plot.maxY, width: layout.plot.width, height: 0)
310
+ content.position(x: layout.plot.minX + layout.plot.width * props.x0, y: band.midY)
311
+ case "yLabel":
312
+ let band = layout.yBand ?? CGRect(x: layout.plot.maxX, y: layout.plot.minY, width: 0, height: layout.plot.height)
313
+ content.position(x: band.midX, y: layout.plot.minY + layout.plot.height * props.y0)
314
+ default:
315
+ let rect = layout.rect(x0: props.x0, x1: props.x1, y0: props.y0, y1: props.y1)
316
+ let spacing = CGFloat(props.spacing)
317
+ let across = Alignment(name: props.alignment)
318
+ Group {
319
+ switch props.position {
320
+ case "top":
321
+ VStack(spacing: spacing) { content; Color.clear.frame(width: rect.width, height: rect.height) }
322
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: across.horizontal, vertical: .bottom))
323
+ case "bottom":
324
+ VStack(spacing: spacing) { Color.clear.frame(width: rect.width, height: rect.height); content }
325
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: across.horizontal, vertical: .top))
326
+ case "leading":
327
+ HStack(spacing: spacing) { content; Color.clear.frame(width: rect.width, height: rect.height) }
328
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: .trailing, vertical: across.vertical))
329
+ case "trailing":
330
+ HStack(spacing: spacing) { Color.clear.frame(width: rect.width, height: rect.height); content }
331
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: .leading, vertical: across.vertical))
332
+ default:
333
+ content.frame(width: rect.width, height: rect.height, alignment: across)
334
+ }
335
+ }
336
+ .position(x: rect.midX, y: rect.midY)
337
+ }
338
+ }
339
+ }
340
+ #endif
@@ -0,0 +1,29 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+
7
+ /// `inspector`: `.inspector(isPresented:)` in a regular width. The children
8
+ /// are the modified view's elements and, last, an `inspectorpanel` holding
9
+ /// the inspector's content (its `inspectorColumnWidth` hint rides the
10
+ /// panel content's style). The app owns the binding: `presented` is what it
11
+ /// holds, and the protocol has no dismiss from this side (in a compact width
12
+ /// the app presents a sheet instead and the element never appears).
13
+ struct InspectorElement: View {
14
+ let node: ElementNode
15
+
16
+ var body: some View {
17
+ let props = InspectorProps(props: node.props)
18
+ let panel = node.children.last { $0.kind == "inspectorpanel" }
19
+ ZStack {
20
+ ForEach(node.children.filter { $0.kind != "inspectorpanel" }) { ElementView(node: $0) }
21
+ }
22
+ .inspector(isPresented: Binding(get: { props.presented }, set: { _ in })) {
23
+ if let panel {
24
+ ZStack { ChildrenView(node: panel) }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ #endif
@@ -155,7 +155,14 @@ struct NavigationScreenElement: View {
155
155
  case "principal": return .principal
156
156
  case "secondary": return .secondaryAction
157
157
  case "bottom": return bottomPlacement
158
- default: return trailingPlacement // `trailing`, and `largeTitle` beside it
158
+ case "largeTitle":
159
+ // Beside the large title: the shim's own placement; SwiftUI has none, so the bar's trailing edge.
160
+ #if canImport(FoundationBridge)
161
+ return .largeTitle
162
+ #else
163
+ return trailingPlacement
164
+ #endif
165
+ default: return trailingPlacement
159
166
  }
160
167
  }
161
168
  }
@@ -11,6 +11,7 @@ struct VStackElement: View {
11
11
  VStack(alignment: HorizontalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
12
12
  ChildrenView(node: node)
13
13
  }
14
+ .scrollTargetLayout(isEnabled: props.scrollTarget)
14
15
  }
15
16
  }
16
17
 
@@ -32,6 +33,7 @@ struct HStackElement: View {
32
33
  HStack(alignment: VerticalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
33
34
  ChildrenView(node: node)
34
35
  }
36
+ .scrollTargetLayout(isEnabled: props.scrollTarget)
35
37
  }
36
38
  }
37
39
  }
@@ -106,21 +108,71 @@ struct GeometryElement: View {
106
108
  }
107
109
  }
108
110
 
111
+ /// `scrollview`: its axes and indicators; `refreshable` sends `refresh`;
112
+ /// `snap` is its scroll target behavior (the stack inside marked
113
+ /// `scrollTarget` is the layout whose children snap); `reportsPosition`
114
+ /// is `.scrollPosition(id:)`, which sends `scrollposition` with the marked
115
+ /// element at the anchor once the view settles. A `ScrollViewReader`'s
116
+ /// `proxy.scrollTo(x)` arrives as a `scrollTo` hint, with a new serial each
117
+ /// time, on the marked element it names (`scrollID`, `.id(x)`): the view
118
+ /// finds the latest in its content and scrolls to it.
109
119
  struct ScrollViewElement: View {
110
120
  let node: ElementNode
111
121
  @Environment(\.nativeApp) private var app
122
+ @State private var position: Int?
123
+ @State private var served: Double = -1
112
124
 
113
125
  var body: some View {
114
126
  let props = ScrollViewProps(props: node.props)
115
127
  let axes: Axis.Set = props.axes == "horizontal" ? .horizontal : props.axes == "both" ? [.horizontal, .vertical] : .vertical
116
- let scroll = ScrollView(axes, showsIndicators: props.showsIndicators) {
117
- ChildrenView(node: node)
128
+ let request = Self.scrollRequest(in: node)
129
+ ScrollViewReader { proxy in
130
+ let scroll = ScrollView(axes, showsIndicators: props.showsIndicators) {
131
+ ChildrenView(node: node)
132
+ }
133
+ let refreshed: AnyView = props.refreshable ? AnyView(scroll.refreshable { app?.dispatch(.refresh(node.id)) }) : AnyView(scroll)
134
+ let snapped: AnyView = props.snap == "paging" ? AnyView(refreshed.scrollTargetBehavior(.paging))
135
+ : props.snap == "viewAligned" ? AnyView(refreshed.scrollTargetBehavior(.viewAligned)) : refreshed
136
+ let positioned: AnyView = props.reportsPosition
137
+ ? AnyView(snapped.scrollPosition(id: Binding(get: { position }, set: { next in
138
+ guard next != position else { return }
139
+ position = next
140
+ if let next { app?.dispatch(.scrollPosition(next)) }
141
+ }), anchor: props.positionAnchor.map(UnitPoint.init(spec:))))
142
+ : snapped
143
+ positioned
144
+ .onAppear { perform(request, with: proxy) }
145
+ .onChange(of: request?.serial) { _, _ in perform(request, with: proxy) }
118
146
  }
119
- if props.refreshable {
120
- scroll.refreshable { app?.dispatch(.refresh(node.id)) }
121
- } else {
122
- scroll
147
+ }
148
+
149
+ private func perform(_ request: ScrollRequest?, with proxy: ScrollViewProxy) {
150
+ guard let request, request.serial > served else { return }
151
+ served = request.serial
152
+ position = request.id
153
+ proxy.scrollTo(request.id, anchor: request.anchor)
154
+ }
155
+
156
+ struct ScrollRequest: Equatable {
157
+ var serial: Double
158
+ var id: Int
159
+ var anchor: UnitPoint?
160
+ }
161
+
162
+ /// The newest `scrollTo` among the content's marked elements (not those of a scroll view nested inside).
163
+ static func scrollRequest(in node: ElementNode) -> ScrollRequest? {
164
+ var best: ScrollRequest?
165
+ func visit(_ element: ElementNode) {
166
+ for child in element.children {
167
+ if child.kind == "scrollview" || child.kind == "list" { continue }
168
+ if child.kind == "styled", let request = child.style.scrollTo, best == nil || request.serial > best!.serial {
169
+ best = ScrollRequest(serial: request.serial, id: child.id, anchor: request.anchor.map(UnitPoint.init(spec:)))
170
+ }
171
+ visit(child)
172
+ }
123
173
  }
174
+ visit(node)
175
+ return best
124
176
  }
125
177
  }
126
178
 
@@ -11,7 +11,7 @@ import SwiftUI
11
11
  /// Type-erased step by step: a chain of sixty optional modifiers would
12
12
  /// otherwise be one type the compiler has to name.
13
13
  extension View {
14
- func styled(_ style: Style) -> AnyView {
14
+ func styled(_ style: Style, id: Int? = nil) -> AnyView {
15
15
  var view = AnyView(self)
16
16
 
17
17
  // Text attributes, which flow down the environment. A font with only a
@@ -71,16 +71,31 @@ extension View {
71
71
  }
72
72
  }
73
73
  if let frame = style.frame {
74
- if frame.isFlexible {
74
+ // Per axis, a fixed length wins over a min/max, as the web renderer
75
+ // lays it out: `{ maxWidth: "infinity", height: 1 }` is
76
+ // `.frame(height: 1).frame(maxWidth: .infinity)`, a rule across
77
+ // the width.
78
+ let alignment = Alignment(name: frame.alignment)
79
+ if frame.width != nil || frame.height != nil {
80
+ view = AnyView(view.frame(width: frame.width.map { CGFloat($0) }, height: frame.height.map { CGFloat($0) }, alignment: alignment))
81
+ }
82
+ let flexibleWidth = frame.width == nil && (frame.minWidth != nil || frame.maxWidth != nil)
83
+ let flexibleHeight = frame.height == nil && (frame.minHeight != nil || frame.maxHeight != nil)
84
+ if flexibleWidth || flexibleHeight {
75
85
  view = AnyView(view.frame(
76
- minWidth: CGFloat(dimension: frame.minWidth), maxWidth: CGFloat(dimension: frame.maxWidth),
77
- minHeight: CGFloat(dimension: frame.minHeight), maxHeight: CGFloat(dimension: frame.maxHeight),
78
- alignment: Alignment(name: frame.alignment)
86
+ minWidth: flexibleWidth ? CGFloat(dimension: frame.minWidth) : nil, maxWidth: flexibleWidth ? CGFloat(dimension: frame.maxWidth) : nil,
87
+ minHeight: flexibleHeight ? CGFloat(dimension: frame.minHeight) : nil, maxHeight: flexibleHeight ? CGFloat(dimension: frame.maxHeight) : nil,
88
+ alignment: alignment
79
89
  ))
80
- } else {
81
- view = AnyView(view.frame(width: frame.width.map { CGFloat($0) }, height: frame.height.map { CGFloat($0) }, alignment: Alignment(name: frame.alignment)))
82
90
  }
83
91
  }
92
+ if let relative = style.containerRelativeFrame {
93
+ var axes: Axis.Set = []
94
+ if relative.horizontal { axes.insert(.horizontal) }
95
+ if relative.vertical { axes.insert(.vertical) }
96
+ let a = relative.a, b = relative.b
97
+ view = AnyView(view.containerRelativeFrame(axes, alignment: Alignment(name: relative.alignment)) { length, _ in a * length + b })
98
+ }
84
99
  if let ratio = style.aspectRatio {
85
100
  let mode: ContentMode = style.aspectRatioContentMode == "fill" ? .fill : .fit
86
101
  view = AnyView(view.aspectRatio(ratio.map { CGFloat($0) }, contentMode: mode))
@@ -127,6 +142,9 @@ extension View {
127
142
  if style.hitTesting == false { view = AnyView(view.allowsHitTesting(false)) }
128
143
  if style.hidden { view = AnyView(view.hidden()) }
129
144
  if let help = style.help { view = AnyView(view.help(help)) }
145
+ // A `ScrollViewReader`'s `.id(x)`: the element's own id names it to a `scrollTo` and a scroll position.
146
+ if style.scrollID, let id { view = AnyView(view.id(id)) }
147
+ if let width = style.raw.double("inspectorColumnWidth") { view = AnyView(view.inspectorColumnWidth(width)) }
130
148
 
131
149
  // Lists.
132
150
  if let badge = style.badge { view = AnyView(view.badge(Text(badge))) }