@swiftbrowser/native 0.0.0-stage → 0.10.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.
- package/README.md +143 -2
- package/bin/swiftbrowser-native.mjs +49 -0
- package/dist/bridge.d.ts +50 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +10 -0
- package/dist/bridge.js.map +1 -0
- package/dist/build.d.ts +47 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +156 -0
- package/dist/build.js.map +1 -0
- package/dist/devBuild.d.ts +34 -0
- package/dist/devBuild.d.ts.map +1 -0
- package/dist/devBuild.js +133 -0
- package/dist/devBuild.js.map +1 -0
- package/dist/encoding.d.ts +32 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +136 -0
- package/dist/encoding.js.map +1 -0
- package/dist/fetch.d.ts +117 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +272 -0
- package/dist/fetch.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +19 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +17 -0
- package/dist/native.js.map +1 -0
- package/dist/refresh.d.ts +18 -0
- package/dist/refresh.d.ts.map +1 -0
- package/dist/refresh.js +18 -0
- package/dist/refresh.js.map +1 -0
- package/dist/run.d.ts +27 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +76 -0
- package/dist/run.js.map +1 -0
- package/dist/runtime.d.ts +30 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +193 -0
- package/dist/runtime.js.map +1 -0
- package/dist/stubs/react-dom-client.d.ts +8 -0
- package/dist/stubs/react-dom-client.d.ts.map +1 -0
- package/dist/stubs/react-dom-client.js +11 -0
- package/dist/stubs/react-dom-client.js.map +1 -0
- package/dist/stubs/react-dom.d.ts +9 -0
- package/dist/stubs/react-dom.d.ts.map +1 -0
- package/dist/stubs/react-dom.js +13 -0
- package/dist/stubs/react-dom.js.map +1 -0
- package/dist/url.d.ts +48 -0
- package/dist/url.d.ts.map +1 -0
- package/dist/url.js +213 -0
- package/dist/url.js.map +1 -0
- package/dist/vendor.d.ts +11 -0
- package/dist/vendor.d.ts.map +1 -0
- package/dist/vendor.js +27 -0
- package/dist/vendor.js.map +1 -0
- package/dist/wasmHost.d.ts +35 -0
- package/dist/wasmHost.d.ts.map +1 -0
- package/dist/wasmHost.js +120 -0
- package/dist/wasmHost.js.map +1 -0
- package/package.json +73 -4
- package/src/bridge.ts +58 -0
- package/src/build.ts +185 -0
- package/src/devBuild.ts +152 -0
- package/src/encoding.ts +130 -0
- package/src/fetch.ts +305 -0
- package/src/index.ts +20 -0
- package/src/native.ts +35 -0
- package/src/refresh.ts +29 -0
- package/src/run.ts +98 -0
- package/src/runtime.ts +208 -0
- package/src/stubs/react-dom-client.ts +17 -0
- package/src/stubs/react-dom.ts +15 -0
- package/src/types/react-refresh.d.ts +38 -0
- package/src/url.ts +202 -0
- package/src/vendor.ts +30 -0
- package/src/wasmHost.ts +147 -0
- package/swift/Package.swift +31 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +228 -0
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +42 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +102 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +25 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebHost.swift +138 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +251 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +53 -0
- package/swift/Sources/SwiftBrowserNative/SwiftBrowserNative.swift +6 -0
- package/swift/Sources/SwiftBrowserNative/Views/Conversions.swift +361 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +169 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +268 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +175 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +196 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +246 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +108 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +189 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +102 -0
- package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +190 -0
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +182 -0
- package/swift/Sources/SwiftBrowserProtocol/JSONValue.swift +268 -0
- package/swift/Sources/SwiftBrowserProtocol/Op.swift +100 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +469 -0
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +117 -0
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +148 -0
- package/swift/Sources/SwiftBrowserProtocol/Values.swift +279 -0
- package/swift/Sources/SwiftBrowserTree/ElementNode.swift +65 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +168 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A `styled` element's style: every field optional, each one modifier of
|
|
4
|
+
/// the app's (docs/ops-protocol.md, "Style"; `interface Style` in
|
|
5
|
+
/// Web/src/protocol.ts). The fields a renderer draws are typed here; `raw`
|
|
6
|
+
/// keeps the whole object, so a field this type does not name is still there
|
|
7
|
+
/// to read.
|
|
8
|
+
public struct Style: Equatable, Sendable {
|
|
9
|
+
public var raw: JSONObject
|
|
10
|
+
|
|
11
|
+
public var padding: EdgeInsetsSpec?
|
|
12
|
+
public var font: FontSpec?
|
|
13
|
+
public var foreground: ShapeStyleSpec?
|
|
14
|
+
public var foregroundLevels: [ShapeStyleSpec]
|
|
15
|
+
public var background: ShapeStyleSpec?
|
|
16
|
+
public var frame: FrameSpec?
|
|
17
|
+
public var cornerRadius: Double?
|
|
18
|
+
public var backgroundCornerRadius: Double?
|
|
19
|
+
public var opacity: Double?
|
|
20
|
+
public var disabled: Bool?
|
|
21
|
+
public var layoutPriority: Double?
|
|
22
|
+
/// `.lineLimit(n)`; `lineLimitLifted` when the app passed `nil` to lift an inherited limit.
|
|
23
|
+
public var lineLimit: Int?
|
|
24
|
+
public var lineLimitLifted: Bool
|
|
25
|
+
public var lineLimitMin: Int?
|
|
26
|
+
public var multilineTextAlignment: String?
|
|
27
|
+
public var fixedSizeHorizontal: Bool?
|
|
28
|
+
public var fixedSizeVertical: Bool?
|
|
29
|
+
public var animation: AnimationSpec?
|
|
30
|
+
public var animationToken: Double?
|
|
31
|
+
public var labelIcon: Bool
|
|
32
|
+
public var transition: [TransitionSpec]?
|
|
33
|
+
public var aspectRatio: Double??
|
|
34
|
+
public var aspectRatioContentMode: String?
|
|
35
|
+
public var offset: PointSpec?
|
|
36
|
+
public var rotation: Double?
|
|
37
|
+
public var rotationAnchor: UnitPointSpec?
|
|
38
|
+
public var scale: PointSpec?
|
|
39
|
+
public var scaleAnchor: UnitPointSpec?
|
|
40
|
+
public var shadow: ShadowSpec?
|
|
41
|
+
public var clipped: Bool
|
|
42
|
+
public var tint: ColorSpec?
|
|
43
|
+
public var position: PointSpec?
|
|
44
|
+
public var badge: String?
|
|
45
|
+
public var listRowInsets: EdgeInsetsSpec?
|
|
46
|
+
public var listRowBackground: ShapeStyleSpec?
|
|
47
|
+
public var imageScale: String?
|
|
48
|
+
public var hidden: Bool
|
|
49
|
+
public var mask: ShapeStyleSpec?
|
|
50
|
+
public var hitTesting: Bool?
|
|
51
|
+
public var blendMode: String?
|
|
52
|
+
public var zIndex: Double?
|
|
53
|
+
public var blur: Double?
|
|
54
|
+
public var brightness: Double?
|
|
55
|
+
public var saturation: Double?
|
|
56
|
+
public var contrast: Double?
|
|
57
|
+
public var grayscale: Double?
|
|
58
|
+
public var hueRotation: Double?
|
|
59
|
+
public var colorInvert: Bool
|
|
60
|
+
public var colorMultiply: ColorSpec?
|
|
61
|
+
public var underline: Bool?
|
|
62
|
+
public var strikethrough: Bool?
|
|
63
|
+
public var italic: Bool?
|
|
64
|
+
/// `.ignoresSafeArea(edges:)`: the edges, or all four for `"all"`. Nil when not set.
|
|
65
|
+
public var ignoresSafeArea: [String]?
|
|
66
|
+
public var ignoresKeyboard: Bool
|
|
67
|
+
public var help: String?
|
|
68
|
+
public var matchedGeometry: String?
|
|
69
|
+
public var contentTransition: String?
|
|
70
|
+
|
|
71
|
+
public init(json: JSONValue) {
|
|
72
|
+
let fields = json.object ?? [:]
|
|
73
|
+
raw = fields
|
|
74
|
+
padding = EdgeInsetsSpec(json: fields.value("padding"))
|
|
75
|
+
font = FontSpec(json: fields.value("font"))
|
|
76
|
+
foreground = ShapeStyleSpec(json: fields.value("foreground"))
|
|
77
|
+
foregroundLevels = (fields.array("foregroundLevels") ?? []).compactMap(ShapeStyleSpec.init(json:))
|
|
78
|
+
background = ShapeStyleSpec(json: fields.value("background"))
|
|
79
|
+
frame = FrameSpec(json: fields.value("frame"))
|
|
80
|
+
cornerRadius = fields.double("cornerRadius")
|
|
81
|
+
backgroundCornerRadius = fields.double("backgroundCornerRadius")
|
|
82
|
+
opacity = fields.double("opacity")
|
|
83
|
+
disabled = fields.bool("disabled")
|
|
84
|
+
layoutPriority = fields.double("layoutPriority")
|
|
85
|
+
lineLimit = fields.int("lineLimit")
|
|
86
|
+
lineLimitLifted = fields["lineLimit"]?.isNull ?? false
|
|
87
|
+
lineLimitMin = fields.int("lineLimitMin")
|
|
88
|
+
multilineTextAlignment = fields.string("multilineTextAlignment")
|
|
89
|
+
fixedSizeHorizontal = fields.object("fixedSize")?.bool("horizontal")
|
|
90
|
+
fixedSizeVertical = fields.object("fixedSize")?.bool("vertical")
|
|
91
|
+
animation = AnimationSpec(json: fields.value("animation"))
|
|
92
|
+
animationToken = fields.double("animationToken")
|
|
93
|
+
labelIcon = fields.bool("labelIcon") ?? false
|
|
94
|
+
transition = fields["transition"].map(TransitionSpec.list(json:))
|
|
95
|
+
if let ratio = fields.object("aspectRatio") {
|
|
96
|
+
aspectRatio = .some(ratio.double("ratio"))
|
|
97
|
+
aspectRatioContentMode = ratio.string("contentMode")
|
|
98
|
+
} else {
|
|
99
|
+
aspectRatio = nil
|
|
100
|
+
aspectRatioContentMode = nil
|
|
101
|
+
}
|
|
102
|
+
offset = PointSpec(json: fields.value("offset"))
|
|
103
|
+
rotation = fields.double("rotation")
|
|
104
|
+
rotationAnchor = UnitPointSpec(json: fields.value("rotationAnchor"))
|
|
105
|
+
scale = PointSpec(json: fields.value("scale"))
|
|
106
|
+
scaleAnchor = UnitPointSpec(json: fields.value("scaleAnchor"))
|
|
107
|
+
shadow = ShadowSpec(json: fields.value("shadow"))
|
|
108
|
+
clipped = fields.bool("clipped") ?? false
|
|
109
|
+
tint = ColorSpec(json: fields.value("tint"))
|
|
110
|
+
position = PointSpec(json: fields.value("position"))
|
|
111
|
+
badge = fields.string("badge")
|
|
112
|
+
listRowInsets = EdgeInsetsSpec(json: fields.value("listRowInsets"))
|
|
113
|
+
listRowBackground = ShapeStyleSpec(json: fields.value("listRowBackground"))
|
|
114
|
+
imageScale = fields.string("imageScale")
|
|
115
|
+
hidden = fields.bool("hidden") ?? false
|
|
116
|
+
mask = ShapeStyleSpec(json: fields.value("mask"))
|
|
117
|
+
hitTesting = fields.bool("hitTesting")
|
|
118
|
+
blendMode = fields.string("blendMode")
|
|
119
|
+
zIndex = fields.double("zIndex")
|
|
120
|
+
blur = fields.double("blur")
|
|
121
|
+
brightness = fields.double("brightness")
|
|
122
|
+
saturation = fields.double("saturation")
|
|
123
|
+
contrast = fields.double("contrast")
|
|
124
|
+
grayscale = fields.double("grayscale")
|
|
125
|
+
hueRotation = fields.double("hueRotation")
|
|
126
|
+
colorInvert = fields.bool("colorInvert") ?? false
|
|
127
|
+
colorMultiply = ColorSpec(json: fields.value("colorMultiply"))
|
|
128
|
+
underline = fields.bool("underline")
|
|
129
|
+
strikethrough = fields.bool("strikethrough")
|
|
130
|
+
italic = fields.bool("italic")
|
|
131
|
+
if fields.string("ignoresSafeArea") == "all" {
|
|
132
|
+
ignoresSafeArea = ["top", "leading", "bottom", "trailing"]
|
|
133
|
+
} else {
|
|
134
|
+
ignoresSafeArea = fields.array("ignoresSafeArea")?.compactMap(\.string)
|
|
135
|
+
}
|
|
136
|
+
ignoresKeyboard = fields.bool("ignoresKeyboard") ?? false
|
|
137
|
+
help = fields.string("help")
|
|
138
|
+
matchedGeometry = fields.string("matchedGeometry")
|
|
139
|
+
contentTransition = fields.object("contentTransition")?.string("kind")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public init() {
|
|
143
|
+
self.init(json: .object([:]))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// The style's field names, for the unsupported-field diagnostics and tests.
|
|
147
|
+
public var fieldNames: [String] { raw.keys.sorted() }
|
|
148
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// The value types of docs/ops-protocol.md (Web/src/protocol.ts), read from
|
|
4
|
+
// their JSON. Each `init?(json:)` accepts the forms the protocol defines and
|
|
5
|
+
// answers nil for anything else, so a renderer falls back to a default rather
|
|
6
|
+
// than failing on a field it has not met.
|
|
7
|
+
|
|
8
|
+
/// A color: explicit sRGB, a semantic name (`primary`, `blue`, `systemBackground`…)
|
|
9
|
+
/// with an opacity multiplier, or an asset catalog color set.
|
|
10
|
+
public enum ColorSpec: Equatable, Sendable {
|
|
11
|
+
case rgba(r: Double, g: Double, b: Double, a: Double)
|
|
12
|
+
case named(String, opacity: Double)
|
|
13
|
+
case asset(String, opacity: Double)
|
|
14
|
+
|
|
15
|
+
public init?(json: JSONValue) {
|
|
16
|
+
guard let fields = json.object else { return nil }
|
|
17
|
+
if let r = fields.double("r"), let g = fields.double("g"), let b = fields.double("b") {
|
|
18
|
+
self = .rgba(r: r, g: g, b: b, a: fields.double("a") ?? 1)
|
|
19
|
+
} else if let name = fields.string("name") {
|
|
20
|
+
self = .named(name, opacity: fields.double("a") ?? 1)
|
|
21
|
+
} else if let name = fields.string("named") {
|
|
22
|
+
self = .asset(name, opacity: fields.double("opacity") ?? 1)
|
|
23
|
+
} else {
|
|
24
|
+
return nil
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// A unit point in a view's bounds (0...1 on each axis).
|
|
30
|
+
public struct UnitPointSpec: Equatable, Sendable {
|
|
31
|
+
public var x: Double
|
|
32
|
+
public var y: Double
|
|
33
|
+
public init(x: Double, y: Double) {
|
|
34
|
+
self.x = x
|
|
35
|
+
self.y = y
|
|
36
|
+
}
|
|
37
|
+
public init?(json: JSONValue) {
|
|
38
|
+
guard let x = json["x"].double, let y = json["y"].double else { return nil }
|
|
39
|
+
self.init(x: x, y: y)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public struct GradientStopSpec: Equatable, Sendable {
|
|
44
|
+
public var color: ColorSpec
|
|
45
|
+
public var location: Double
|
|
46
|
+
public init?(json: JSONValue) {
|
|
47
|
+
guard let color = ColorSpec(json: json["color"]), let location = json["location"].double else { return nil }
|
|
48
|
+
self.color = color
|
|
49
|
+
self.location = location
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Everything a view can be painted with (`ShapeStyle` in protocol.ts).
|
|
54
|
+
public enum ShapeStyleSpec: Equatable, Sendable {
|
|
55
|
+
case color(ColorSpec)
|
|
56
|
+
/// `.primary` ... `.quaternary` (1...4).
|
|
57
|
+
case hierarchical(level: Int, opacity: Double)
|
|
58
|
+
case linearGradient(stops: [GradientStopSpec], start: UnitPointSpec, end: UnitPointSpec, opacity: Double)
|
|
59
|
+
case radialGradient(stops: [GradientStopSpec], center: UnitPointSpec, startRadius: Double, endRadius: Double, opacity: Double)
|
|
60
|
+
case angularGradient(stops: [GradientStopSpec], center: UnitPointSpec, startAngle: Double, endAngle: Double, opacity: Double)
|
|
61
|
+
/// `Color.gradient`: a subtle vertical gradient from one color.
|
|
62
|
+
case colorGradient(ColorSpec, opacity: Double)
|
|
63
|
+
/// `ultraThin` ... `ultraThick`, `glass`, `glassClear`.
|
|
64
|
+
case material(String, opacity: Double)
|
|
65
|
+
|
|
66
|
+
public init?(json: JSONValue) {
|
|
67
|
+
guard let fields = json.object else { return nil }
|
|
68
|
+
let opacity = fields.double("opacity") ?? 1
|
|
69
|
+
if let color = ColorSpec(json: json) {
|
|
70
|
+
self = .color(color)
|
|
71
|
+
} else if let level = fields.int("hierarchical") {
|
|
72
|
+
self = .hierarchical(level: level, opacity: opacity)
|
|
73
|
+
} else if let gradient = fields.object("linearGradient"),
|
|
74
|
+
let start = UnitPointSpec(json: gradient.value("start")), let end = UnitPointSpec(json: gradient.value("end"))
|
|
75
|
+
{
|
|
76
|
+
self = .linearGradient(stops: ShapeStyleSpec.stops(gradient), start: start, end: end, opacity: opacity)
|
|
77
|
+
} else if let gradient = fields.object("radialGradient"), let center = UnitPointSpec(json: gradient.value("center")) {
|
|
78
|
+
self = .radialGradient(
|
|
79
|
+
stops: ShapeStyleSpec.stops(gradient), center: center,
|
|
80
|
+
startRadius: gradient.double("startRadius") ?? 0, endRadius: gradient.double("endRadius") ?? 0, opacity: opacity
|
|
81
|
+
)
|
|
82
|
+
} else if let gradient = fields.object("angularGradient"), let center = UnitPointSpec(json: gradient.value("center")) {
|
|
83
|
+
self = .angularGradient(
|
|
84
|
+
stops: ShapeStyleSpec.stops(gradient), center: center,
|
|
85
|
+
startAngle: gradient.double("startAngle") ?? 0, endAngle: gradient.double("endAngle") ?? 360, opacity: opacity
|
|
86
|
+
)
|
|
87
|
+
} else if let color = ColorSpec(json: fields.value("colorGradient")) {
|
|
88
|
+
self = .colorGradient(color, opacity: opacity)
|
|
89
|
+
} else if let material = fields.string("material") {
|
|
90
|
+
self = .material(material, opacity: opacity)
|
|
91
|
+
} else {
|
|
92
|
+
return nil
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private static func stops(_ gradient: JSONObject) -> [GradientStopSpec] {
|
|
97
|
+
(gradient.array("stops") ?? []).compactMap(GradientStopSpec.init(json:))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// `padding`, `listRowInsets`: each edge optional.
|
|
102
|
+
public struct EdgeInsetsSpec: Equatable, Sendable {
|
|
103
|
+
public var top: Double?
|
|
104
|
+
public var leading: Double?
|
|
105
|
+
public var bottom: Double?
|
|
106
|
+
public var trailing: Double?
|
|
107
|
+
|
|
108
|
+
public init(top: Double? = nil, leading: Double? = nil, bottom: Double? = nil, trailing: Double? = nil) {
|
|
109
|
+
self.top = top
|
|
110
|
+
self.leading = leading
|
|
111
|
+
self.bottom = bottom
|
|
112
|
+
self.trailing = trailing
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public init?(json: JSONValue) {
|
|
116
|
+
guard let fields = json.object else { return nil }
|
|
117
|
+
self.init(top: fields.double("top"), leading: fields.double("leading"), bottom: fields.double("bottom"), trailing: fields.double("trailing"))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// `font`: a text style that scales with Dynamic Type, or a fixed size
|
|
122
|
+
/// (`relativeTo` names the style it scales like), with weight and design.
|
|
123
|
+
public struct FontSpec: Equatable, Sendable {
|
|
124
|
+
public var textStyle: String?
|
|
125
|
+
public var size: Double?
|
|
126
|
+
public var relativeTo: String?
|
|
127
|
+
public var weight: String?
|
|
128
|
+
public var design: String?
|
|
129
|
+
public var monospacedDigit: Bool
|
|
130
|
+
|
|
131
|
+
public init?(json: JSONValue) {
|
|
132
|
+
guard let fields = json.object else { return nil }
|
|
133
|
+
textStyle = fields.string("textStyle")
|
|
134
|
+
size = fields.double("size")
|
|
135
|
+
relativeTo = fields.string("relativeTo")
|
|
136
|
+
weight = fields.string("weight")
|
|
137
|
+
design = fields.string("design")
|
|
138
|
+
monospacedDigit = fields.bool("monospacedDigit") ?? false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// A frame dimension: points, or `"infinity"` to fill the available space.
|
|
143
|
+
public enum DimensionSpec: Equatable, Sendable {
|
|
144
|
+
case points(Double)
|
|
145
|
+
case infinity
|
|
146
|
+
|
|
147
|
+
public init?(json: JSONValue) {
|
|
148
|
+
if let value = json.double { self = .points(value) } else if json.string == "infinity" { self = .infinity } else { return nil }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// `.frame(width:height:)` or `.frame(minWidth:…)` with the content's alignment.
|
|
153
|
+
public struct FrameSpec: Equatable, Sendable {
|
|
154
|
+
public var width: Double?
|
|
155
|
+
public var height: Double?
|
|
156
|
+
public var minWidth: DimensionSpec?
|
|
157
|
+
public var maxWidth: DimensionSpec?
|
|
158
|
+
public var minHeight: DimensionSpec?
|
|
159
|
+
public var maxHeight: DimensionSpec?
|
|
160
|
+
public var alignment: String
|
|
161
|
+
|
|
162
|
+
/// Whether this is the flexible form (any min/max), which `.frame(minWidth:…)` draws.
|
|
163
|
+
public var isFlexible: Bool { minWidth != nil || maxWidth != nil || minHeight != nil || maxHeight != nil }
|
|
164
|
+
|
|
165
|
+
public init?(json: JSONValue) {
|
|
166
|
+
guard let fields = json.object else { return nil }
|
|
167
|
+
width = fields.double("width")
|
|
168
|
+
height = fields.double("height")
|
|
169
|
+
minWidth = DimensionSpec(json: fields.value("minWidth"))
|
|
170
|
+
maxWidth = DimensionSpec(json: fields.value("maxWidth"))
|
|
171
|
+
minHeight = DimensionSpec(json: fields.value("minHeight"))
|
|
172
|
+
maxHeight = DimensionSpec(json: fields.value("maxHeight"))
|
|
173
|
+
alignment = fields.string("alignment") ?? "center"
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// A `commit`'s animation, or a `styled` element's `.animation(_:value:)`.
|
|
178
|
+
public struct AnimationSpec: Equatable, Sendable {
|
|
179
|
+
/// `default`, `linear`, `easeIn`, `easeOut`, `easeInOut`, `spring`.
|
|
180
|
+
public var type: String
|
|
181
|
+
/// Seconds; 0.35 when absent. A spring's perceptual duration.
|
|
182
|
+
public var duration: Double?
|
|
183
|
+
/// A spring's bounce (0 critically damped).
|
|
184
|
+
public var bounce: Double?
|
|
185
|
+
public var delay: Double?
|
|
186
|
+
public var repeatCount: Int?
|
|
187
|
+
public var repeatsForever: Bool
|
|
188
|
+
public var autoreverses: Bool
|
|
189
|
+
|
|
190
|
+
public init?(json: JSONValue) {
|
|
191
|
+
guard let fields = json.object, let type = fields.string("type") else { return nil }
|
|
192
|
+
self.type = type
|
|
193
|
+
duration = fields.double("duration")
|
|
194
|
+
bounce = fields.double("bounce")
|
|
195
|
+
delay = fields.double("delay")
|
|
196
|
+
let repeatSpec = fields.object("repeat")
|
|
197
|
+
repeatCount = repeatSpec?.int("count")
|
|
198
|
+
repeatsForever = repeatSpec?.string("count") == "forever"
|
|
199
|
+
autoreverses = repeatSpec?.bool("autoreverses") ?? false
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
public var json: JSONValue {
|
|
203
|
+
var fields: JSONObject = ["type": .string(type)]
|
|
204
|
+
if let duration { fields["duration"] = .number(duration) }
|
|
205
|
+
if let bounce { fields["bounce"] = .number(bounce) }
|
|
206
|
+
if let delay { fields["delay"] = .number(delay) }
|
|
207
|
+
if repeatCount != nil || repeatsForever {
|
|
208
|
+
fields["repeat"] = .object([
|
|
209
|
+
"count": repeatsForever ? .string("forever") : .number(Double(repeatCount ?? 1)),
|
|
210
|
+
"autoreverses": .bool(autoreverses),
|
|
211
|
+
])
|
|
212
|
+
}
|
|
213
|
+
return .object(fields)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/// One part of a `transition`: `opacity`, `scale`, `slide`, `move` (with an
|
|
218
|
+
/// edge), `offset` (x, y), `identity`, or `asymmetric` (insertion and removal).
|
|
219
|
+
public struct TransitionSpec: Equatable, Sendable {
|
|
220
|
+
public var kind: String
|
|
221
|
+
public var edge: String?
|
|
222
|
+
public var scale: Double?
|
|
223
|
+
public var x: Double?
|
|
224
|
+
public var y: Double?
|
|
225
|
+
public var insertion: [TransitionSpec]?
|
|
226
|
+
public var removal: [TransitionSpec]?
|
|
227
|
+
|
|
228
|
+
public init?(json: JSONValue) {
|
|
229
|
+
guard let fields = json.object, let kind = fields.string("kind") else { return nil }
|
|
230
|
+
self.kind = kind
|
|
231
|
+
edge = fields.string("edge")
|
|
232
|
+
scale = fields.double("scale")
|
|
233
|
+
x = fields.double("x")
|
|
234
|
+
y = fields.double("y")
|
|
235
|
+
insertion = fields["insertion"].map(TransitionSpec.list(json:))
|
|
236
|
+
removal = fields["removal"].map(TransitionSpec.list(json:))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/// A transition field: one part or a combined list of parts.
|
|
240
|
+
public static func list(json: JSONValue) -> [TransitionSpec] {
|
|
241
|
+
if let parts = json.array { return parts.compactMap(TransitionSpec.init(json:)) }
|
|
242
|
+
return TransitionSpec(json: json).map { [$0] } ?? []
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
public struct ShadowSpec: Equatable, Sendable {
|
|
247
|
+
public var color: ColorSpec?
|
|
248
|
+
public var radius: Double
|
|
249
|
+
public var x: Double
|
|
250
|
+
public var y: Double
|
|
251
|
+
|
|
252
|
+
public init?(json: JSONValue) {
|
|
253
|
+
guard let fields = json.object, let radius = fields.double("radius") else { return nil }
|
|
254
|
+
color = ColorSpec(json: fields.value("color"))
|
|
255
|
+
self.radius = radius
|
|
256
|
+
x = fields.double("x") ?? 0
|
|
257
|
+
y = fields.double("y") ?? 0
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
public struct PointSpec: Equatable, Sendable {
|
|
262
|
+
public var x: Double
|
|
263
|
+
public var y: Double
|
|
264
|
+
public init?(json: JSONValue) {
|
|
265
|
+
guard let fields = json.object else { return nil }
|
|
266
|
+
x = fields.double("x") ?? 0
|
|
267
|
+
y = fields.double("y") ?? 0
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
public struct SizeSpec: Equatable, Sendable {
|
|
272
|
+
public var width: Double
|
|
273
|
+
public var height: Double
|
|
274
|
+
public init?(json: JSONValue) {
|
|
275
|
+
guard let width = json["width"].double, let height = json["height"].double else { return nil }
|
|
276
|
+
self.width = width
|
|
277
|
+
self.height = height
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Observation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
|
|
5
|
+
/// One element of the live tree: its id (stable for its life, assigned by the
|
|
6
|
+
/// producer), its kind, its props as the last `create` or `update` left them,
|
|
7
|
+
/// and its children in order.
|
|
8
|
+
///
|
|
9
|
+
/// Observable, so a SwiftUI view that reads one node's props re-evaluates
|
|
10
|
+
/// when that node changes and not when another does: the granularity the
|
|
11
|
+
/// protocol's `update` op already works at.
|
|
12
|
+
@Observable
|
|
13
|
+
public final class ElementNode: Identifiable {
|
|
14
|
+
public let id: Int
|
|
15
|
+
public let kind: String
|
|
16
|
+
public internal(set) var props: JSONObject
|
|
17
|
+
public internal(set) var children: [ElementNode] = []
|
|
18
|
+
public internal(set) weak var parent: ElementNode?
|
|
19
|
+
|
|
20
|
+
init(id: Int, kind: String, props: JSONObject) {
|
|
21
|
+
self.id = id
|
|
22
|
+
self.kind = kind
|
|
23
|
+
self.props = props
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// The root container, element id 0: never created or removed.
|
|
27
|
+
static func root() -> ElementNode { ElementNode(id: 0, kind: "root", props: [:]) }
|
|
28
|
+
|
|
29
|
+
/// The `styled` element's style (an empty style on any other kind).
|
|
30
|
+
public var style: Style { Style(json: props.value("style")) }
|
|
31
|
+
|
|
32
|
+
/// The first child, the one a wrapper kind (`styled`, `button`, `geometry`…) has.
|
|
33
|
+
public var child: ElementNode? { children.first }
|
|
34
|
+
|
|
35
|
+
/// The children of a kind.
|
|
36
|
+
public func children(ofKind kind: String) -> [ElementNode] { children.filter { $0.kind == kind } }
|
|
37
|
+
|
|
38
|
+
/// The children that are not of the given kinds (a screen's content, without its toolbars).
|
|
39
|
+
public func children(except kinds: Set<String>) -> [ElementNode] { children.filter { !kinds.contains($0.kind) } }
|
|
40
|
+
|
|
41
|
+
/// The index of this element among its parent's children, or nil when detached.
|
|
42
|
+
public var indexInParent: Int? { parent?.children.firstIndex { $0 === self } }
|
|
43
|
+
|
|
44
|
+
/// The nearest ancestor of a kind.
|
|
45
|
+
public func ancestor(ofKind kind: String) -> ElementNode? {
|
|
46
|
+
var node = parent
|
|
47
|
+
while let current = node {
|
|
48
|
+
if current.kind == kind { return current }
|
|
49
|
+
node = current.parent
|
|
50
|
+
}
|
|
51
|
+
return nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// The tree below this node as indented lines, `kind#id {props}`, for tests and logs.
|
|
55
|
+
public func dump(indent: Int = 0) -> String {
|
|
56
|
+
var lines: [String] = []
|
|
57
|
+
func visit(_ node: ElementNode, depth: Int) {
|
|
58
|
+
let props = node.props.isEmpty ? "" : " " + JSONValue.object(node.props).json
|
|
59
|
+
lines.append(String(repeating: " ", count: depth) + "\(node.kind)#\(node.id)\(props)")
|
|
60
|
+
for child in node.children { visit(child, depth: depth + 1) }
|
|
61
|
+
}
|
|
62
|
+
visit(self, depth: indent)
|
|
63
|
+
return lines.joined(separator: "\n")
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SwiftBrowserProtocol
|
|
3
|
+
|
|
4
|
+
public struct OpTreeError: Error, CustomStringConvertible, Equatable {
|
|
5
|
+
public let message: String
|
|
6
|
+
public init(_ message: String) { self.message = message }
|
|
7
|
+
public var description: String { message }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// The live element tree a renderer draws, kept current by applying ops
|
|
11
|
+
/// (docs/ops-protocol.md, "Ops"). The counterpart of the web renderer's
|
|
12
|
+
/// `elements` and `parents` maps (Web/src/renderer.ts), with Observation
|
|
13
|
+
/// doing the invalidation: each `ElementNode` is observable, so whatever
|
|
14
|
+
/// reads a node re-evaluates when an op changes it.
|
|
15
|
+
///
|
|
16
|
+
/// A pass is the ops up to and including a `commit`; the whole pass is
|
|
17
|
+
/// applied inside one call of `animate`, so a host that sets
|
|
18
|
+
/// `animate` to `withAnimation` gets the protocol's animated commits: every
|
|
19
|
+
/// frame and style change of the pass animates, and children inserted or
|
|
20
|
+
/// removed in it transition. `apply` may be called with several passes at
|
|
21
|
+
/// once, or with part of one (the rest follows before the next commit).
|
|
22
|
+
///
|
|
23
|
+
/// Main-thread only, like the views reading it.
|
|
24
|
+
@MainActor
|
|
25
|
+
public final class OpTree {
|
|
26
|
+
/// The root container, element id 0.
|
|
27
|
+
public let root: ElementNode
|
|
28
|
+
private var nodes: [Int: ElementNode]
|
|
29
|
+
/// The ops of the current pass, until its `commit`.
|
|
30
|
+
private var pending: [Op] = []
|
|
31
|
+
/// How many passes were committed.
|
|
32
|
+
public private(set) var commits = 0
|
|
33
|
+
/// Ops applied that this tree does not render (`defaults`, `request`…), by name, for diagnostics.
|
|
34
|
+
public private(set) var unhandledOps: [String: Int] = [:]
|
|
35
|
+
|
|
36
|
+
/// Runs the body of a pass, animated as the commit asks (`withAnimation` on a SwiftUI host).
|
|
37
|
+
public var animate: (AnimationSpec?, () -> Void) -> Void = { _, body in body() }
|
|
38
|
+
/// Called after every committed pass.
|
|
39
|
+
public var onCommit: ((AnimationSpec?) -> Void)?
|
|
40
|
+
/// Where the renderer's events go (the app's `dispatch`).
|
|
41
|
+
public var onEvent: ((RendererEvent) -> Void)?
|
|
42
|
+
/// Ops this tree does not apply (a `request`, say), for the host to handle.
|
|
43
|
+
public var onOtherOp: ((String, JSONObject) -> Void)?
|
|
44
|
+
|
|
45
|
+
public init() {
|
|
46
|
+
root = ElementNode.root()
|
|
47
|
+
nodes = [0: root]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// The element with this id, when it exists (created and not removed).
|
|
51
|
+
public func node(_ id: Int) -> ElementNode? { nodes[id] }
|
|
52
|
+
|
|
53
|
+
/// How many elements exist, the root included.
|
|
54
|
+
public var count: Int { nodes.count }
|
|
55
|
+
|
|
56
|
+
/// The root's props (`.preferredColorScheme`).
|
|
57
|
+
public var rootProps: RootProps { RootProps(props: root.props) }
|
|
58
|
+
|
|
59
|
+
/// Sends a renderer event to the app.
|
|
60
|
+
public func send(_ event: RendererEvent) {
|
|
61
|
+
onEvent?(event)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Applies ops in order. Throws on an op that cannot apply (an unknown
|
|
65
|
+
/// id, an insert into a missing parent); the ops before it have applied.
|
|
66
|
+
public func apply(_ ops: [Op]) throws {
|
|
67
|
+
for op in ops {
|
|
68
|
+
if case .commit(let animation) = op {
|
|
69
|
+
let pass = pending
|
|
70
|
+
pending.removeAll()
|
|
71
|
+
var failure: Error?
|
|
72
|
+
animate(animation) {
|
|
73
|
+
do {
|
|
74
|
+
for each in pass { try self.applyOne(each) }
|
|
75
|
+
} catch {
|
|
76
|
+
failure = error
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if let failure { throw failure }
|
|
80
|
+
commits += 1
|
|
81
|
+
onCommit?(animation)
|
|
82
|
+
} else {
|
|
83
|
+
pending.append(op)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Applies a JSON array of ops (what crosses the bridge).
|
|
89
|
+
public func apply(json: String) throws {
|
|
90
|
+
try apply(Op.decodeArray(json))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Applies the ops of a pass that had no `commit` yet, now (headless
|
|
94
|
+
/// streams end without one; tests use it).
|
|
95
|
+
public func flush() throws {
|
|
96
|
+
if pending.isEmpty { return }
|
|
97
|
+
try apply([.commit(animation: nil)])
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private func applyOne(_ op: Op) throws {
|
|
101
|
+
switch op {
|
|
102
|
+
case .create(let id, let kind, let props):
|
|
103
|
+
guard id != 0 else { throw OpTreeError("create: id 0 is the root") }
|
|
104
|
+
if let existing = nodes[id] {
|
|
105
|
+
// The producer reuses an id only after removing it; a create
|
|
106
|
+
// for a live id replaces it in place (the web renderer does the same).
|
|
107
|
+
detach(existing)
|
|
108
|
+
}
|
|
109
|
+
nodes[id] = ElementNode(id: id, kind: kind, props: props)
|
|
110
|
+
case .update(let id, let props):
|
|
111
|
+
guard let node = nodes[id] else { throw OpTreeError("update: no element \(id)") }
|
|
112
|
+
if node.props != props { node.props = props }
|
|
113
|
+
case .insert(let parentId, let id, let index):
|
|
114
|
+
guard let node = nodes[id] else { throw OpTreeError("insert: no element \(id)") }
|
|
115
|
+
guard let parent = nodes[parentId] else { throw OpTreeError("insert: no parent \(parentId) for element \(id)") }
|
|
116
|
+
guard id != 0, !isAncestor(node, of: parent) else { throw OpTreeError("insert: element \(id) into its own subtree") }
|
|
117
|
+
if let current = node.parent {
|
|
118
|
+
if current === parent, let at = parent.children.firstIndex(where: { $0 === node }) {
|
|
119
|
+
// A move among siblings.
|
|
120
|
+
if at == index { return }
|
|
121
|
+
var children = parent.children
|
|
122
|
+
children.remove(at: at)
|
|
123
|
+
children.insert(node, at: min(index, children.count))
|
|
124
|
+
parent.children = children
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
detach(node)
|
|
128
|
+
}
|
|
129
|
+
var children = parent.children
|
|
130
|
+
children.insert(node, at: min(max(index, 0), children.count))
|
|
131
|
+
parent.children = children
|
|
132
|
+
node.parent = parent
|
|
133
|
+
case .remove(let id):
|
|
134
|
+
guard id != 0 else { throw OpTreeError("remove: id 0 is the root") }
|
|
135
|
+
guard let node = nodes[id] else { throw OpTreeError("remove: no element \(id)") }
|
|
136
|
+
detach(node)
|
|
137
|
+
forget(node)
|
|
138
|
+
case .commit:
|
|
139
|
+
break
|
|
140
|
+
case .other(let name, let fields):
|
|
141
|
+
unhandledOps[name, default: 0] += 1
|
|
142
|
+
onOtherOp?(name, fields)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private func isAncestor(_ node: ElementNode, of other: ElementNode) -> Bool {
|
|
147
|
+
var current: ElementNode? = other
|
|
148
|
+
while let each = current {
|
|
149
|
+
if each === node { return true }
|
|
150
|
+
current = each.parent
|
|
151
|
+
}
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private func detach(_ node: ElementNode) {
|
|
156
|
+
guard let parent = node.parent else { return }
|
|
157
|
+
parent.children.removeAll { $0 === node }
|
|
158
|
+
node.parent = nil
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private func forget(_ node: ElementNode) {
|
|
162
|
+
nodes[node.id] = nil
|
|
163
|
+
for child in node.children { forget(child) }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// The whole tree as indented lines (tests, logs).
|
|
167
|
+
public func dump() -> String { root.dump() }
|
|
168
|
+
}
|