From 0d61d41d2b3dacd3cf02383cb0477d91ffff5436 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 7 Sep 2025 14:50:11 +0800 Subject: [PATCH 1/2] Add RenderEffect support --- .../Render/DisplayList/DisplayList.swift | 8 + .../DisplayList_StableIdentity.swift | 1 - .../RendererEffect/RendererEffect.swift | 230 +++++++++++++++++- 3 files changed, 229 insertions(+), 10 deletions(-) diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift index ab4f0a737..c7804c47e 100644 --- a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift @@ -433,6 +433,14 @@ extension DisplayList { } } +extension _ViewInputs { + @inline(__always) + var displayListOptions: DisplayList.Options { + get { self[DisplayList.Options.self] } + set { self[DisplayList.Options.self] = newValue } + } +} + extension PreferencesInputs { @inline(__always) package var requiresDisplayList: Bool { diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList_StableIdentity.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList_StableIdentity.swift index a73a187c8..cd47220a2 100644 --- a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList_StableIdentity.swift +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList_StableIdentity.swift @@ -101,7 +101,6 @@ extension _ViewInputs { } package func pushIdentity(_ identity: _DisplayList_Identity) { - guard base.needsStableDisplayListIDs else { return } diff --git a/Sources/OpenSwiftUICore/Render/RendererEffect/RendererEffect.swift b/Sources/OpenSwiftUICore/Render/RendererEffect/RendererEffect.swift index 3ff606281..43c54ce9e 100644 --- a/Sources/OpenSwiftUICore/Render/RendererEffect/RendererEffect.swift +++ b/Sources/OpenSwiftUICore/Render/RendererEffect/RendererEffect.swift @@ -2,12 +2,14 @@ // RendererEffect.swift // OpenSwiftUICore // +// Audited for 6.5.4 // Status: WIP // ID: 49800242E3DD04CB91F7CE115272DDC3 (SwiftUICore) package import Foundation +package import OpenAttributeGraphShims -// MARK: - _RendererEffect [6.5.4] [WIP] +// MARK: - _RendererEffect package protocol _RendererEffect: MultiViewModifier, PrimitiveViewModifier { func effectValue(size: CGSize) -> DisplayList.Effect @@ -20,9 +22,11 @@ package protocol _RendererEffect: MultiViewModifier, PrimitiveViewModifier { static var isScrapeable: Bool { get } - // var scrapeableContent: ScrapeableContent.Content? { get } + var scrapeableContent: ScrapeableContent.Content? { get } } +// MARK: - _RendererEffect + Default Implementation + extension _RendererEffect { package static var isolatesChildPosition: Bool { false @@ -40,23 +44,77 @@ extension _RendererEffect { false } -// package var scrapeableContent: ScrapeableContent.Content? { -// nil -// } + package var scrapeableContent: ScrapeableContent.Content? { + nil + } package static func _makeRendererEffect( effect: _GraphValue, inputs: _ViewInputs, body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs ) -> _ViewOutputs { - if isScrapeable { - // TOOD: Handle scrapeable content + var newInputs = inputs + let scrapeableID: ScrapeableID + if isScrapeable, inputs.isScrapeable { + scrapeableID = .init() + newInputs.scrapeableParentID = scrapeableID + } else { + scrapeableID = .none } - _openSwiftUIUnimplementedFailure() + if inputs.needsGeometry { + if isolatesChildPosition { + let resetTransform = Attribute( + ResetPositionTransform( + position: inputs.animatedPosition(), + transform: inputs.transform + ) + ) + newInputs.transform = resetTransform + let origin = ViewGraph.current.$zeroPoint + let roundedSize = Attribute( + RoundedSize( + position: inputs.containerPosition, + size: inputs.size, + pixelLength: inputs.pixelLength + ) + ) + newInputs.position = origin + newInputs.containerPosition = origin + newInputs.size = roundedSize + } else { + newInputs.containerPosition = inputs.animatedPosition() + } + } + var outputs = body(_Graph(), newInputs) + if inputs.preferences.requiresDisplayList { + let identity = DisplayList.Identity() + inputs.pushIdentity(identity) + let displayList = Attribute( + RendererEffectDisplayList( + identity: identity, + effect: effect.value, + position: inputs.animatedPosition(), + size: inputs.animatedSize(), + transform: inputs.transform, + containerPosition: inputs.containerPosition, + environment: inputs.environment, + safeAreaInsets: inputs.safeAreaInsets, + content: .init(outputs.displayList), + options: inputs.displayListOptions, + localID: scrapeableID, + parentID: inputs.scrapeableParentID + ) + ) + if isScrapeable, inputs.isScrapeable { + displayList.setFlags(.scrapeable, mask: .all) + } + outputs.displayList = displayList + } + return outputs } } -// MARK: - RendererEffect [6.5.4] +// MARK: - RendererEffect package protocol RendererEffect: Animatable, _RendererEffect {} @@ -91,3 +149,157 @@ extension RendererEffect { body(inputs) } } + +// MARK: - ResetPositionTransform + +package struct ResetPositionTransform: Rule, AsyncAttribute { + @Attribute var position: ViewOrigin + @Attribute var transform: ViewTransform + + package init(position: Attribute, transform: Attribute) { + self._position = position + self._transform = transform + } + + package var value: ViewTransform { + transform.withPosition(position) + } +} + +// MARK: - RendererEffectDisplayList + +private struct RendererEffectDisplayList: Rule, AsyncAttribute, ScrapeableAttribute where Effect: _RendererEffect { + let identity: DisplayList.Identity + @Attribute var effect: Effect + @Attribute var position: ViewOrigin + @Attribute var size: ViewSize + @Attribute var transform: ViewTransform + @Attribute var containerPosition: ViewOrigin + @Attribute var environment: EnvironmentValues + @OptionalAttribute var safeAreaInsets: SafeAreaInsets? + @OptionalAttribute var content: DisplayList? + let options: DisplayList.Options + let localID: ScrapeableID + let parentID: ScrapeableID + + var value: DisplayList { + let content = content ?? .init() + guard !content.isEmpty || Effect.preservesEmptyContent else { + return .init() + } + let version = DisplayList.Version(forUpdate: ()) + let proxy = GeometryProxy( + owner: .current!, + size: $size, + environment: $environment, + transform: $transform, + position: $position, + safeAreaInsets: $safeAreaInsets, + seed: .init(bitPattern: numericCast(version.value)) + ) + + let e: DisplayList.Effect + if Effect.disabledForFlattenedContent, content.features.contains(.flattened) { + e = .identity + } else { + e = proxy.asCurrent { + effect.effectValue(size: size.value) + } + } + let frame = CGRect( + origin: CGPoint(position - containerPosition), + size: size.value + ) + var item = DisplayList.Item( + .effect(e, content), + frame: frame, + identity: identity, + version: version + ) + item.canonicalize(options: options) + return DisplayList(item) + } + + static func scrapeContent(from ident: AnyAttribute) -> ScrapeableContent.Item? { + let pointer = ident.info.body.assumingMemoryBound(to: Self.self) + guard let content = pointer.pointee.effect.scrapeableContent else { + return nil + } + return .init( + content, + ids: pointer.pointee.localID, + pointer.pointee.parentID, + position: pointer.pointee.$position, + size: pointer.pointee.$size, + transform: pointer.pointee.$transform + ) + } +} + +// MARK: - RendererEffect conformances [TODO] + +extension GraphicsFilter: RendererEffect { + package func effectValue(size: CGSize) -> DisplayList.Effect { + _openSwiftUIUnimplementedFailure() + } +} + +extension GraphicsBlendMode: RendererEffect { + package func effectValue(size: CGSize) -> DisplayList.Effect { + _openSwiftUIUnimplementedFailure() + } +} + +// MARK: - GeometryGroupEffect + +@available(OpenSwiftUI_v5_0, *) +@frozen +public struct _GeometryGroupEffect: RendererEffect, Equatable { + package static let isolatesChildPosition: Bool = true + + package func effectValue(size: CGSize) -> DisplayList.Effect { + .geometryGroup + } + + @_alwaysEmitIntoClient + nonisolated public init() {} +} + +@available(OpenSwiftUI_v5_0, *) +extension View { + /// Isolates the geometry (e.g. position and size) of the view + /// from its parent view. + /// + /// By default OpenSwiftUI views push position and size changes down + /// through the view hierarchy, so that only views that draw + /// something (known as leaf views) apply the current animation to + /// their frame rectangle. However in some cases this coalescing + /// behavior can give undesirable results; inserting a geometry + /// group can correct that. A group acts as a barrier between the + /// parent view and its subviews, forcing the position and size + /// values to be resolved and animated by the parent, before being + /// passed down to each subview. + /// + /// The example below shows one use of this function: ensuring that + /// the member views of each row in the stack apply (and animate + /// as) a single geometric transform from their ancestor view, + /// rather than letting the effects of the ancestor views be + /// applied separately to each leaf view. If the members of + /// `ItemView` may be added and removed at different times the + /// group ensures that they stay locked together as animations are + /// applied. + /// + /// VStack { + /// ForEach(items) { item in + /// ItemView(item: item) + /// .geometryGroup() + /// } + /// } + /// + /// Returns: a new view whose geometry is isolated from that of its + /// parent view. + @_alwaysEmitIntoClient + nonisolated public func geometryGroup() -> some View { + modifier(_GeometryGroupEffect()) + } +} From d3340d7c3a5887619695435d9ac25a1ba11a21e6 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 7 Sep 2025 15:54:06 +0800 Subject: [PATCH 2/2] Implement BlurStyle and VariableBlurStyle --- .../Graphic/BlurStyle/BlurStyle.swift | 57 ++++++++++ .../Graphic/BlurStyle/VariableBlurStyle.swift | 100 +++++++++++++++++ .../Graphic/GraphicsFilter.swift | 18 +--- .../Render/DisplayList/DisplayList.swift | 18 +++- .../RendererEffect/RendererEffect.swift | 10 +- .../Graphic/BlurStyleTests.swift | 64 +++++++++++ .../Graphic/VariableBlurStyleTests.swift | 101 ++++++++++++++++++ 7 files changed, 349 insertions(+), 19 deletions(-) create mode 100644 Sources/OpenSwiftUICore/Graphic/BlurStyle/BlurStyle.swift create mode 100644 Sources/OpenSwiftUICore/Graphic/BlurStyle/VariableBlurStyle.swift create mode 100644 Tests/OpenSwiftUICoreTests/Graphic/BlurStyleTests.swift create mode 100644 Tests/OpenSwiftUICoreTests/Graphic/VariableBlurStyleTests.swift diff --git a/Sources/OpenSwiftUICore/Graphic/BlurStyle/BlurStyle.swift b/Sources/OpenSwiftUICore/Graphic/BlurStyle/BlurStyle.swift new file mode 100644 index 000000000..1c85420a1 --- /dev/null +++ b/Sources/OpenSwiftUICore/Graphic/BlurStyle/BlurStyle.swift @@ -0,0 +1,57 @@ +// +// BlurStyle.swift +// OpenSwiftUICore +// +// Audited for iOS 6.5.4 +// Status: Complete + +package import Foundation + +package struct BlurStyle: Equatable { + package var radius: CGFloat + package var isOpaque: Bool + package var dither: Bool + + package init( + radius: CGFloat = 0, + isOpaque: Bool = false, + dither: Bool = false, + hardEdges: Bool = false + ) { + self.radius = radius + self.isOpaque = isOpaque + self.dither = dither + } + + package var isIdentity: Bool { + radius <= 0.0 + } +} + +extension BlurStyle: Animatable { + package var animatableData: CGFloat { + get { radius } + set { radius = newValue } + } +} + +extension BlurStyle: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) { + encoder.cgFloatField(1, radius) + encoder.boolField(2, isOpaque) + encoder.boolField(3, dither) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var style = BlurStyle() + while let field = try decoder.nextField() { + switch field.tag { + case 1: style.radius = try decoder.cgFloatField(field) + case 2: style.isOpaque = try decoder.boolField(field) + case 3: style.dither = try decoder.boolField(field) + default: try decoder.skipField(field) + } + } + self = style + } +} diff --git a/Sources/OpenSwiftUICore/Graphic/BlurStyle/VariableBlurStyle.swift b/Sources/OpenSwiftUICore/Graphic/BlurStyle/VariableBlurStyle.swift new file mode 100644 index 000000000..823a8cee3 --- /dev/null +++ b/Sources/OpenSwiftUICore/Graphic/BlurStyle/VariableBlurStyle.swift @@ -0,0 +1,100 @@ +// +// VariableBlurStyle.swift +// OpenSwiftUICore +// +// Audited for iOS 6.5.4 +// Status: Complete + +package import Foundation + +package struct VariableBlurStyle: Equatable { + package enum Mask: Equatable { + case none + case image(GraphicsImage) + } + + package var radius: CGFloat + package var isOpaque: Bool + package var dither: Bool + package var mask: VariableBlurStyle.Mask + + package init( + radius: CGFloat = 0, + isOpaque: Bool = false, + dither: Bool = false, + mask: VariableBlurStyle.Mask = .none + ) { + self.radius = radius + self.isOpaque = isOpaque + self.dither = dither + self.mask = mask + } + + package var caFilterRadius: CGFloat { + get { radius * 0.5 } + set { radius = newValue / 0.5 } + } + + package var isIdentity: Bool { + radius <= 0.0 || mask == .none + } +} + +extension VariableBlurStyle: RendererEffect { + package func effectValue(size: CGSize) -> DisplayList.Effect { + .filter(.variableBlur(self)) + } +} + +extension VariableBlurStyle: Animatable { + package var animatableData: CGFloat { + get { radius } + set { radius = newValue } + } +} + +extension VariableBlurStyle: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + encoder.cgFloatField(1, radius) + encoder.boolField(2, isOpaque) + encoder.boolField(3, dither) + try encoder.messageField(4, mask) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var style = VariableBlurStyle() + while let field = try decoder.nextField() { + switch field.tag { + case 1: style.radius = try decoder.cgFloatField(field) + case 2: style.isOpaque = try decoder.boolField(field) + case 3: style.dither = try decoder.boolField(field) + case 4: style.mask = try decoder.messageField(field) + default: try decoder.skipField(field) + } + } + self = style + } +} + +extension VariableBlurStyle.Mask: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + switch self { + case .none: break + case .image(let image): try encoder.messageField(1, image) + } + } + + package init(from decoder: inout ProtobufDecoder) throws { + var mask = VariableBlurStyle.Mask.none + while let field = try decoder.nextField() { + switch field.tag { + case 1: + let image: GraphicsImage = try decoder.messageField(field) + mask = .image(image) + default: + try decoder.skipField(field) + } + } + self = mask + } +} diff --git a/Sources/OpenSwiftUICore/Graphic/GraphicsFilter.swift b/Sources/OpenSwiftUICore/Graphic/GraphicsFilter.swift index 10f329743..813015363 100644 --- a/Sources/OpenSwiftUICore/Graphic/GraphicsFilter.swift +++ b/Sources/OpenSwiftUICore/Graphic/GraphicsFilter.swift @@ -6,8 +6,8 @@ // WIP package enum GraphicsFilter { - // case blur(BlurStyle) - // case variableBlur(VariableBlurStyle) + case blur(BlurStyle) + case variableBlur(VariableBlurStyle) case averageColor // case shadow(ResolvedShadowStyle) case projection(ProjectionTransform) @@ -36,10 +36,6 @@ package enum GraphicsFilter { self.amount = amount self.bias = bias } - - package static func == (lhs: GraphicsFilter.ColorMonochrome, rhs: GraphicsFilter.ColorMonochrome) -> Bool { - lhs.color == rhs.color && lhs.amount == rhs.amount && lhs.bias == rhs.bias - } } package struct Curve: Equatable { @@ -61,10 +57,6 @@ package enum GraphicsFilter { self.curve = curve self.amount = amount } - - package static func == (a: GraphicsFilter.LuminanceCurve, b: GraphicsFilter.LuminanceCurve) -> Bool { - a.curve == b.curve && a.amount == b.amount - } } package struct ColorCurves: Equatable { @@ -79,10 +71,6 @@ package enum GraphicsFilter { self.blueCurve = blueCurve self.opacityCurve = opacityCurve } - - package static func == (a: GraphicsFilter.ColorCurves, b: GraphicsFilter.ColorCurves) -> Bool { - a.redCurve == b.redCurve && a.greenCurve == b.greenCurve && a.blueCurve == b.blueCurve && a.opacityCurve == b.opacityCurve - } } // package struct ShaderFilter { @@ -98,6 +86,7 @@ package enum GraphicsFilter { package enum GraphicsBlendMode: Equatable { case blendMode(GraphicsContext.BlendMode) + case caFilter(AnyObject) package init(_ mode: BlendMode) { @@ -128,6 +117,7 @@ package enum GraphicsBlendMode: Equatable { } package static let normal: GraphicsBlendMode = .blendMode(.normal) + package static func == (lhs: GraphicsBlendMode, rhs: GraphicsBlendMode) -> Bool { switch (lhs, rhs) { case (.blendMode(let lhs), .blendMode(let rhs)): lhs == rhs diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift index c7804c47e..f44f176a0 100644 --- a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift @@ -514,7 +514,23 @@ public struct ContentTransition { package struct State {} } -package struct GraphicsImage {} +package struct GraphicsImage: Equatable { + package init() {} +} + +extension GraphicsImage: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + // GraphicsImage is currently empty, no fields to encode + } + + package init(from decoder: inout ProtobufDecoder) throws { + // GraphicsImage is currently empty, skip all fields + while let field = try decoder.nextField() { + try decoder.skipField(field) + } + self = GraphicsImage() + } +} package struct ResolvedShadowStyle {} package struct StyledTextContentView {} diff --git a/Sources/OpenSwiftUICore/Render/RendererEffect/RendererEffect.swift b/Sources/OpenSwiftUICore/Render/RendererEffect/RendererEffect.swift index 43c54ce9e..b24a04cbc 100644 --- a/Sources/OpenSwiftUICore/Render/RendererEffect/RendererEffect.swift +++ b/Sources/OpenSwiftUICore/Render/RendererEffect/RendererEffect.swift @@ -3,7 +3,7 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: WIP +// Status: Complete // ID: 49800242E3DD04CB91F7CE115272DDC3 (SwiftUICore) package import Foundation @@ -236,17 +236,19 @@ private struct RendererEffectDisplayList: Rule, AsyncAttribute, Scrapeab } } -// MARK: - RendererEffect conformances [TODO] +// MARK: - GraphicsFilter + RendererEffect extension GraphicsFilter: RendererEffect { package func effectValue(size: CGSize) -> DisplayList.Effect { - _openSwiftUIUnimplementedFailure() + .filter(self) } } +// MARK: - GraphicsBlendMode + RendererEffect + extension GraphicsBlendMode: RendererEffect { package func effectValue(size: CGSize) -> DisplayList.Effect { - _openSwiftUIUnimplementedFailure() + .blendMode(self) } } diff --git a/Tests/OpenSwiftUICoreTests/Graphic/BlurStyleTests.swift b/Tests/OpenSwiftUICoreTests/Graphic/BlurStyleTests.swift new file mode 100644 index 000000000..b3794b702 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Graphic/BlurStyleTests.swift @@ -0,0 +1,64 @@ +// +// BlurStyleTests.swift +// OpenSwiftUICoreTests + +import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +struct BlurStyleTests { + @Test + func blurStyleInit() { + let style = BlurStyle(radius: 10, isOpaque: true, dither: true) + #expect(style.radius == 10) + #expect(style.isOpaque == true) + #expect(style.dither == true) + } + + @Test + func blurStyleIsIdentity() { + let style1 = BlurStyle(radius: 0) + #expect(style1.isIdentity == true) + + let style2 = BlurStyle(radius: -5) + #expect(style2.isIdentity == true) + + let style3 = BlurStyle(radius: 10) + #expect(style3.isIdentity == false) + } + + @Test + func blurStyleEquality() { + let style1 = BlurStyle(radius: 10, isOpaque: true, dither: false) + let style2 = BlurStyle(radius: 10, isOpaque: true, dither: false) + let style3 = BlurStyle(radius: 10, isOpaque: false, dither: false) + + #expect(style1 == style2) + #expect(style1 != style3) + } + + @Test + func blurStyleAnimatableData() { + var style = BlurStyle(radius: 10) + #expect(style.animatableData.isApproximatelyEqual(to: 10)) + + style.animatableData = 20 + #expect(style.radius.isApproximatelyEqual(to: 20)) + } + + // MARK: - ProtobufMessage Tests + + @Test( + arguments: [ + (BlurStyle(), ""), + (BlurStyle(radius: 10.0), "0d00002041"), + (BlurStyle(radius: 10.0, isOpaque: true), "0d000020411001"), + (BlurStyle(radius: 10.0, isOpaque: true, dither: true), "0d0000204110011801"), + (BlurStyle(radius: 10.0, isOpaque: false, dither: true), "0d000020411801"), + ] + ) + func pbMessage(style: BlurStyle, hexString: String) throws { + try style.testPBEncoding(hexString: hexString) + try style.testPBDecoding(hexString: hexString) + } +} diff --git a/Tests/OpenSwiftUICoreTests/Graphic/VariableBlurStyleTests.swift b/Tests/OpenSwiftUICoreTests/Graphic/VariableBlurStyleTests.swift new file mode 100644 index 000000000..52f165714 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Graphic/VariableBlurStyleTests.swift @@ -0,0 +1,101 @@ +// +// VariableBlurStyleTests.swift +// OpenSwiftUICoreTests + +import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +struct VariableBlurStyleTests { + @Test + func variableBlurStyleInit() { + let style = VariableBlurStyle(radius: 10, isOpaque: true, dither: true, mask: .none) + #expect(style.radius == 10) + #expect(style.isOpaque == true) + #expect(style.dither == true) + #expect(style.mask == .none) + } + + @Test + func variableBlurStyleIsIdentity() { + let style1 = VariableBlurStyle(radius: 0) + #expect(style1.isIdentity == true) + + let style2 = VariableBlurStyle(radius: -5) + #expect(style2.isIdentity == true) + + let style3 = VariableBlurStyle(radius: 10, mask: .none) + #expect(style3.isIdentity == true) + + let style4 = VariableBlurStyle(radius: 10, mask: .image(GraphicsImage())) + #expect(style4.isIdentity == false) + } + + @Test + func variableBlurStyleEquality() { + let style1 = VariableBlurStyle(radius: 10, isOpaque: true, dither: false, mask: .none) + let style2 = VariableBlurStyle(radius: 10, isOpaque: true, dither: false, mask: .none) + let style3 = VariableBlurStyle(radius: 10, isOpaque: false, dither: false, mask: .none) + + #expect(style1 == style2) + #expect(style1 != style3) + } + + @Test + func variableBlurStyleAnimatableData() { + var style = VariableBlurStyle(radius: 10) + #expect(style.animatableData.isApproximatelyEqual(to: 10)) + + style.animatableData = 20 + #expect(style.radius.isApproximatelyEqual(to: 20)) + } + + @Test + func variableBlurStyleCAFilterRadius() { + var style = VariableBlurStyle(radius: 10) + #expect(style.caFilterRadius.isApproximatelyEqual(to: 5)) + + style.caFilterRadius = 10 + #expect(style.radius.isApproximatelyEqual(to: 20)) + } + + @Test + func variableBlurStyleMaskEquality() { + let mask1 = VariableBlurStyle.Mask.none + let mask2 = VariableBlurStyle.Mask.none + let mask3 = VariableBlurStyle.Mask.image(GraphicsImage()) + let mask4 = VariableBlurStyle.Mask.image(GraphicsImage()) + + #expect(mask1 == mask2) + #expect(mask3 == mask4) + #expect(mask1 != mask3) + } + + // MARK: - ProtobufMessage Tests + + @Test( + arguments: [ + (VariableBlurStyle(), "2200"), + (VariableBlurStyle(radius: 10.0), "0d000020412200"), + (VariableBlurStyle(radius: 10.0, isOpaque: true), "0d0000204110012200"), + (VariableBlurStyle(radius: 10.0, isOpaque: true, dither: true), "0d00002041100118012200"), + (VariableBlurStyle(radius: 10.0, isOpaque: false, dither: true, mask: .none), "0d0000204118012200"), + (VariableBlurStyle(radius: 10.0, mask: .image(GraphicsImage())), "0d0000204122020a00"), + ] + ) + func pbMessage(style: VariableBlurStyle, hexString: String) throws { + try style.testPBEncoding(hexString: hexString) + try style.testPBDecoding(hexString: hexString) + } + + @Test( + arguments: [ + (VariableBlurStyle.Mask.none, ""), + (VariableBlurStyle.Mask.image(GraphicsImage()), "0a00"), + ] + ) + func maskPBMessage(mask: VariableBlurStyle.Mask, hexString: String) throws { + try mask.testPBEncoding(hexString: hexString) + try mask.testPBDecoding(hexString: hexString) + } +}