diff --git a/.github/dule-test.prompt.md b/.github/dule-test.prompt.md new file mode 100644 index 000000000..e947472d5 --- /dev/null +++ b/.github/dule-test.prompt.md @@ -0,0 +1,122 @@ +# OpenSwiftUI Copilot Test Authoring Rules + +This file documents precise rules and templates for writing Tests and DualTests used across the OpenSwiftUI repository. Follow these rules exactly when generating new test cases. + +## General principles +- Use the `swift-testing` framework and the `#expect` macro for all assertions. +- Do NOT use XCTest unless explicitly required for compatibility. +- Keep test function bodies free of comments. +- Use descriptive test function names (do not prefix with `test`). +- Use `@Test` for each test function. +- Group related tests with `// MARK: -`. +- Keep tests small and focused. +- Use `package` / access-level rules only inside implementation files — tests import the target. + +## File layout and naming +- Test filenames should end with `Tests.swift`. +- Put project-only tests in `Tests/*Tests`. +- Put dual tests that compare against SwiftUI in `Tests/*SymbolDualTests`. +- Use `struct Tests { ... }` to group tests. + +## Test file headers and conditions + +Project-only tests (call OpenSwiftUI APIs): +- Use compile condition matching the implementation (example: `#if os(iOS) && canImport(QuartzCore)`). +- Required imports: + - `import Testing` + - `@_spi(ForOpenSwiftUIOnly) import OpenSwiftUICore` (if testing internal SPI) + - platform framework imports (e.g., `import QuartzCore`) + +DualTests (call into SwiftUI symbol via stub): +- Use a condition that ensures SwiftUI is available and matches the reference SDK: + - Example: `#if os(iOS) && canImport(SwiftUI, _underlyingVersion: 6.5.4)` +- Required imports: + - `import QuartzCore` + - `import Testing` +- Provide a `@_silgen_name` initializer (or function) declaration on the type to call the SwiftUI symbol: + - Example: + extension CAFrameRateRange { + @_silgen_name("OpenSwiftUITestStub_CAFrameRateRangeInitInterval") + init(swiftUI_interval: Double) + } +- Provide a C stub that publishes the SwiftUI symbol name for the test bundle (placed in SymbolDualTestsSupport target). The C file must define the same symbol name used in `@_silgen_name`. + +## Test implementation rules +- Use `@Test(arguments: [...])` to run parameterized cases when appropriate. +- Argument arrays must be typed where necessary (e.g., `as [(Double, CAFrameRateRange)]`). +- In each `@Test` function: + - Instantiate or call the API under test. + - Use `#expect(...)` for every assertion. + - Do not include comments inside the test function body. +- Use exact equality checks where types are Equatable (e.g., `#expect(range == expected)`). + +## Small templates + +Project test template: +```swift +// ... file header and #if condition ... +import Testing +@_spi(ForOpenSwiftUIOnly) import OpenSwiftUICore +import QuartzCore + +struct Tests { + @Test(arguments: [ + // example tuples + ] as [(/* types */)]) + func (/* params */) { + let result = /* call OpenSwiftUI API */ + #expect(result == expected) + } +} +``` + +Dual test template: +```swift +// ... file header and #if condition ... +import QuartzCore +import Testing + +extension { + @_silgen_name("") + init(swiftUI_: /* type */) +} + +struct DualTests { + @Test(arguments: [ + // example tuples + ] as [(/* types */)]) + func (/* params */) { + let result = (swiftUI_: input) + #expect(result == expected) + } +} +``` + +C stub template (SymbolDualTestsSupport target): +```c +// Provide a C stub that resolves to the SwiftUI initializer/function +// Use the same symbol name used in @_silgen_name in the DualTest +#include "OpenSwiftUIBase.h" + +#if OPENSWIFTUI_TARGET_OS_IOS + +#import + +DEFINE_SL_STUB_SLF(, SwiftUICore, ); + +#endif +``` + +## Examples and important notes +- Do not include test-comments in function bodies. +- Keep parameterized arguments typed explicitly when the compiler cannot infer them. +- Match the platform compile conditions to the implementation file being tested. +- When comparing framework-provided types (e.g., `CAFrameRateRange`) rely on their Equatable conformance using `==`. +- Ensure the C stub symbol name and the `@_silgen_name` string are identical. + +## Checklist before committing test code +- [ ] Uses `Testing` and `#expect` exclusively. +- [ ] No comments inside `@Test` function bodies. +- [ ] Proper compile-time conditions are present. +- [ ] DualTests have matching C stubs in the SymbolDualTestsSupport target. +- [ ] Test file and struct names follow repository conventions. diff --git a/Example/HostingExample/ViewController.swift b/Example/HostingExample/ViewController.swift index f7b8e69b4..104385da2 100644 --- a/Example/HostingExample/ViewController.swift +++ b/Example/HostingExample/ViewController.swift @@ -66,6 +66,6 @@ class ViewController: NSViewController { struct ContentView: View { var body: some View { - ColorAnimationExample() + GeometryEffectExample() } } diff --git a/Example/OpenSwiftUIUITests/Render/GeometryEffect/Rotation3DEffectUITests.swift b/Example/OpenSwiftUIUITests/Render/GeometryEffect/Rotation3DEffectUITests.swift new file mode 100644 index 000000000..dc77817ec --- /dev/null +++ b/Example/OpenSwiftUIUITests/Render/GeometryEffect/Rotation3DEffectUITests.swift @@ -0,0 +1,40 @@ +// +// Rotation3DEffectUITests.swift +// OpenSwiftUIUITests + +import Testing +import SnapshotTesting + +@MainActor +@Suite(.snapshots(record: .never, diffTool: diffTool)) +struct Rotation3DEffectUITests { + @Test + func rotation3DEffect() { + struct ContentView: View { + var body: some View { + Color.blue + .frame(width: 80, height: 60) + .rotation3DEffect( + .degrees(45), + axis: (x: 0, y: 1, z: 0), + anchor: .center, + anchorZ: 0, + perspective: 1 + ) + .background(Color.red) + .overlay( + Color.green + .frame(width: 40, height: 30) + .rotation3DEffect( + .degrees(-45), + axis: (x: 1, y: 0, z: 0), + anchor: .center, + anchorZ: 0, + perspective: 1 + ) + ) + } + } + openSwiftUIAssertSnapshot(of: ContentView()) + } +} diff --git a/Example/OpenSwiftUIUITests/Render/GeometryEffect/RotationEffectUITests.swift b/Example/OpenSwiftUIUITests/Render/GeometryEffect/RotationEffectUITests.swift new file mode 100644 index 000000000..4e2bea22d --- /dev/null +++ b/Example/OpenSwiftUIUITests/Render/GeometryEffect/RotationEffectUITests.swift @@ -0,0 +1,28 @@ +// +// RotationEffectUITests.swift +// OpenSwiftUIUITests + +import Testing +import SnapshotTesting + +@MainActor +@Suite(.snapshots(record: .never, diffTool: diffTool)) +struct RotationEffectUITests { + @Test + func rotationEffect() { + struct ContentView: View { + var body: some View { + Color.blue + .frame(width: 80, height: 60) + .rotationEffect(.degrees(30)) + .background(Color.red) + .overlay( + Color.green + .frame(width: 40, height: 30) + .rotationEffect(.degrees(-30)) + ) + } + } + openSwiftUIAssertSnapshot(of: ContentView()) + } +} diff --git a/Example/SharedExample/Render/GeometryEffect/GeometryEffectExample.swift b/Example/SharedExample/Render/GeometryEffect/GeometryEffectExample.swift new file mode 100644 index 000000000..635be3a64 --- /dev/null +++ b/Example/SharedExample/Render/GeometryEffect/GeometryEffectExample.swift @@ -0,0 +1,69 @@ +// +// GeometryEffectExample.swift +// SharedExample + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +struct GeometryEffectExample: View { + var body: some View { + VStack(spacing: 50) { + OffsetEffectExample() + RotationEffectExample() + Rotation3DEffectExample() + } + } +} + +struct OffsetEffectExample: View { + var body: some View { + Color.blue + .offset(x: 20, y: 15) + .frame(width: 80, height: 60) + .background(Color.red) + .overlay(Color.green.offset(x: 40, y: 30)) + } +} + +struct RotationEffectExample: View { + var body: some View { + Color.blue + .frame(width: 80, height: 60) + .rotationEffect(.degrees(30)) + .background(Color.red) + .overlay( + Color.green + .frame(width: 40, height: 30) + .rotationEffect(.degrees(-30)) + ) + } +} + +struct Rotation3DEffectExample: View { + var body: some View { + Color.blue + .frame(width: 80, height: 60) + .rotation3DEffect( + .degrees(45), + axis: (x: 0, y: 1, z: 0), + anchor: .center, + anchorZ: 0, + perspective: 1 + ) + .background(Color.red) + .overlay( + Color.green + .frame(width: 40, height: 30) + .rotation3DEffect( + .degrees(-45), + axis: (x: 1, y: 0, z: 0), + anchor: .center, + anchorZ: 0, + perspective: 1 + ) + ) + } +} diff --git a/Sources/CoreGraphicsShims/CATransform3D.swift b/Sources/CoreGraphicsShims/CATransform3D.swift index aea622fe3..25aea2ed7 100644 --- a/Sources/CoreGraphicsShims/CATransform3D.swift +++ b/Sources/CoreGraphicsShims/CATransform3D.swift @@ -103,6 +103,15 @@ public func CATransform3DMakeAffineTransform(_ m: CGAffineTransform) -> CATransf ) } +public func CATransform3DMakeTranslation(_ tx: CGFloat, _ ty: CGFloat, _ tz: CGFloat) -> CATransform3D { + return CATransform3D( + m11: 1, m12: 0, m13: 0, m14: 0, + m21: 0, m22: 1, m23: 0, m24: 0, + m31: 0, m32: 0, m33: 1, m34: 0, + m41: tx, m42: ty, m43: tz, m44: 1 + ) +} + public func CATransform3DMakeScale(_ sx: CGFloat, _ sy: CGFloat, _ sz: CGFloat) -> CATransform3D { return CATransform3D( m11: sx, m12: 0, m13: 0, m14: 0, @@ -112,15 +121,90 @@ public func CATransform3DMakeScale(_ sx: CGFloat, _ sy: CGFloat, _ sz: CGFloat) ) } -public func CATransform3DMakeTranslation(_ tx: CGFloat, _ ty: CGFloat, _ tz: CGFloat) -> CATransform3D { +/// Returns a transform that rotates by `angle` radians about the vector +/// `(x, y, z)`. If the vector has length zero the identity transform is +/// returned. +public func CATransform3DMakeRotation(_ angle: CGFloat, _ x: CGFloat, _ y: CGFloat, _ z: CGFloat) -> CATransform3D { + let ax = Double(x) + let ay = Double(y) + let az = Double(z) + let len = sqrt(ax * ax + ay * ay + az * az) + guard len > 0 else { + return CATransform3DIdentity + } + + let ux = CGFloat(ax / len) + let uy = CGFloat(ay / len) + let uz = CGFloat(az / len) + + let a = Double(angle) + let c = CGFloat(cos(a)) + let s = CGFloat(sin(a)) + let omc = 1 - c + + // Compute standard rotation matrix components (Rodrigues' formula) + let r11 = c + ux * ux * omc + let r12 = ux * uy * omc - uz * s + let r13 = ux * uz * omc + uy * s + + let r21 = uy * ux * omc + uz * s + let r22 = c + uy * uy * omc + let r23 = uy * uz * omc - ux * s + + let r31 = uz * ux * omc - uy * s + let r32 = uz * uy * omc + ux * s + let r33 = c + uz * uz * omc + + // Store the transpose of the standard rotation matrix into CATransform3D + // so that the effective transform used by this implementation matches the + // expected row/column layout when applied to points. + let m11 = r11 + let m12 = r21 + let m13 = r31 + let m14: CGFloat = 0.0 + + let m21 = r12 + let m22 = r22 + let m23 = r32 + let m24: CGFloat = 0.0 + + let m31 = r13 + let m32 = r23 + let m33 = r33 + let m34: CGFloat = 0.0 + + let m41: CGFloat = 0.0 + let m42: CGFloat = 0.0 + let m43: CGFloat = 0.0 + let m44: CGFloat = 1.0 + return CATransform3D( - m11: 1, m12: 0, m13: 0, m14: 0, - m21: 0, m22: 1, m23: 0, m24: 0, - m31: 0, m32: 0, m33: 1, m34: 0, - m41: tx, m42: ty, m43: tz, m44: 1 + m11: m11, m12: m12, m13: m13, m14: m14, + m21: m21, m22: m22, m23: m23, m24: m24, + m31: m31, m32: m32, m33: m33, m34: m34, + m41: m41, m42: m42, m43: m43, m44: m44 ) } +/// Translate `t` by `(tx, ty, tz)` and return the result: +/// t' = translate(tx, ty, tz) * t. +public func CATransform3DTranslate(_ t: CATransform3D, _ tx: CGFloat, _ ty: CGFloat, _ tz: CGFloat) -> CATransform3D { + return CATransform3DConcat(CATransform3DMakeTranslation(tx, ty, tz), t) +} + +/// Scale `t` by `(sx, sy, sz)` and return the result: +/// t' = scale(sx, sy, sz) * t. +public func CATransform3DScale(_ t: CATransform3D, _ sx: CGFloat, _ sy: CGFloat, _ sz: CGFloat) -> CATransform3D { + return CATransform3DConcat(CATransform3DMakeScale(sx, sy, sz), t) +} + +/// Rotate `t` by `angle` radians about the vector `(x, y, z)` and return +/// the result. If the vector has zero length the behavior is undefined: +/// t' = rotation(angle, x, y, z) * t. +public func CATransform3DRotate(_ t: CATransform3D, _ angle: CGFloat, _ x: CGFloat, _ y: CGFloat, _ z: CGFloat) -> CATransform3D { + return CATransform3DConcat(CATransform3DMakeRotation(angle, x, y, z), t) +} + public func CATransform3DConcat(_ a: CATransform3D, _ b: CATransform3D) -> CATransform3D { if a == CATransform3DIdentity { return b } if b == CATransform3DIdentity { return a } diff --git a/Sources/CoreGraphicsShims/CGAffineTransform.swift b/Sources/CoreGraphicsShims/CGAffineTransform.swift index deba2c741..561ccace7 100644 --- a/Sources/CoreGraphicsShims/CGAffineTransform.swift +++ b/Sources/CoreGraphicsShims/CGAffineTransform.swift @@ -6,19 +6,6 @@ public import Foundation public struct CGAffineTransform: Equatable { - public init(translationX tx: CGFloat, y ty: CGFloat) { - self.init(a: 1, b: 0, c: 0, d: 1, tx: Double(tx), ty: Double(ty)) - } - - public init(scaleX sx: CGFloat, y sy: CGFloat) { - self.init(a: Double(sx), b: 0, c: 0, d: Double(sy), tx: 0, ty: 0) - } - - public init(rotationAngle angle: CGFloat) { - let radians = Double(angle) - self.init(a: cos(radians), b: sin(radians), c: -sin(radians), d: cos(radians), tx: 0, ty: 0) - } - public init() { a = .zero b = .zero @@ -36,40 +23,113 @@ public struct CGAffineTransform: Equatable { self.tx = tx self.ty = ty } - + public var a: Double public var b: Double public var c: Double public var d: Double public var tx: Double public var ty: Double - + public static let identity = CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0) - - public func concatenating(_ transform: CGAffineTransform) -> CGAffineTransform { - preconditionFailure("Unimplemented") +} + +extension CGAffineTransform { + public init(translationX tx: CGFloat, y ty: CGFloat) { + self.init(a: 1, b: 0, c: 0, d: 1, tx: Double(tx), ty: Double(ty)) + } + + public init(scaleX sx: CGFloat, y sy: CGFloat) { + self.init(a: Double(sx), b: 0, c: 0, d: Double(sy), tx: 0, ty: 0) + } + + public init(rotationAngle angle: CGFloat) { + let radians = Double(angle) + self.init(a: cos(radians), b: sin(radians), c: -sin(radians), d: cos(radians), tx: 0, ty: 0) + } + + public var isIdentity: Bool { + return self == CGAffineTransform.identity + } + + public func translatedBy(x tx: CGFloat, y ty: CGFloat) -> CGAffineTransform { + let t = CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: Double(tx), ty: Double(ty)) + return concatenating(t) + } + + public func scaledBy(x sx: CGFloat, y sy: CGFloat) -> CGAffineTransform { + let s = CGAffineTransform(a: Double(sx), b: 0, c: 0, d: Double(sy), tx: 0, ty: 0) + return concatenating(s) } - + + public func rotated(by angle: CGFloat) -> CGAffineTransform { + return concatenating(CGAffineTransform(rotationAngle: angle)) + } + public func inverted() -> CGAffineTransform { - preconditionFailure("Unimplemented") + let det = a * d - b * c + if abs(det) < 1e-12 { + return self + } + let inv = 1.0 / det + let ra = d * inv + let rb = -b * inv + let rc = -c * inv + let rd = a * inv + let rtx = -(tx * ra + ty * rc) + let rty = -(tx * rb + ty * rd) + return CGAffineTransform(a: ra, b: rb, c: rc, d: rd, tx: rtx, ty: rty) + } + + public func concatenating(_ transform: CGAffineTransform) -> CGAffineTransform { + // Matrix multiplication: self * transform + let a1 = a, b1 = b, c1 = c, d1 = d, tx1 = tx, ty1 = ty + let a2 = transform.a, b2 = transform.b, c2 = transform.c, d2 = transform.d, tx2 = transform.tx, ty2 = transform.ty + + let ra = a1 * a2 + b1 * c2 + let rb = a1 * b2 + b1 * d2 + let rc = c1 * a2 + d1 * c2 + let rd = c1 * b2 + d1 * d2 + let rtx = tx1 * a2 + ty1 * c2 + tx2 + let rty = tx1 * b2 + ty1 * d2 + ty2 + + return CGAffineTransform(a: ra, b: rb, c: rc, d: rd, tx: rtx, ty: rty) } } extension CGPoint { public func applying(_ t: CGAffineTransform) -> CGPoint { - preconditionFailure("Unimplemented") + CGPoint( + x: t.a * x + t.c * y + t.tx, + y: t.b * x + t.d * y + t.ty + ) } } extension CGSize { public func applying(_ t: CGAffineTransform) -> CGSize { - preconditionFailure("Unimplemented") + let rect = CGRect(origin: .zero, size: self).applying(t) + return rect.size } } extension CGRect { public func applying(_ t: CGAffineTransform) -> CGRect { - preconditionFailure("Unimplemented") + // Transform all four corners and compute bounding rect + let p1 = CGPoint(x: minX, y: minY).applying(t) + let p2 = CGPoint(x: maxX, y: minY).applying(t) + let p3 = CGPoint(x: minX, y: maxY).applying(t) + let p4 = CGPoint(x: maxX, y: maxY).applying(t) + + let xs = [p1.x, p2.x, p3.x, p4.x] + let ys = [p1.y, p2.y, p3.y, p4.y] + + let minX = xs.min() ?? 0 + let maxX = xs.max() ?? 0 + let minY = ys.min() ?? 0 + let maxY = ys.max() ?? 0 + + return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) } } diff --git a/Sources/CoreGraphicsShims/Export.swift b/Sources/CoreGraphicsShims/Export.swift index 54aa663ed..f597eb30a 100644 --- a/Sources/CoreGraphicsShims/Export.swift +++ b/Sources/CoreGraphicsShims/Export.swift @@ -4,6 +4,8 @@ #if canImport(CoreGraphics) @_exported import CoreGraphics +#else +@_exported import Foundation #endif #if canImport(QuartzCore) diff --git a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSInheritedView.swift b/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSInheritedView.swift index c80b7346a..6a79a2170 100644 --- a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSInheritedView.swift +++ b/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSInheritedView.swift @@ -10,7 +10,7 @@ import OpenSwiftUI_SPI import AppKit -final class _NSInheritedView: NSView { +class _NSInheritedView: NSView { var hitTestsAsOpaque: Bool = false override init(frame frameRect: NSRect) { diff --git a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSProjectionView.swift b/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSProjectionView.swift deleted file mode 100644 index f5497c381..000000000 --- a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSProjectionView.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// NSProjectionView.swift -// OpenSwiftUI -// -// Audited for macOS 15.0 -// Status: WIP - -#if os(macOS) - -import OpenSwiftUI_SPI -import AppKit - -final class _NSProjectionView: NSView { - - override var wantsUpdateLayer: Bool { true } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - } -} - -#endif diff --git a/Sources/OpenSwiftUI/Integration/Render/AppKit/NSViewPlatformViewDefinition.swift b/Sources/OpenSwiftUI/Integration/Render/AppKit/NSViewPlatformViewDefinition.swift index 741293c84..9be6d3183 100644 --- a/Sources/OpenSwiftUI/Integration/Render/AppKit/NSViewPlatformViewDefinition.swift +++ b/Sources/OpenSwiftUI/Integration/Render/AppKit/NSViewPlatformViewDefinition.swift @@ -2,8 +2,9 @@ // NSViewPlatformViewDefinition.swift // OpenSwiftUI // -// Audited for macOS 15.0 +// Audited for 6.0.87 // Status: WIP +// ID: 33EEAA67E0460DA84AE814EA027152BA (SwiftUI?) #if os(macOS) @_spi(DisplayList_ViewSystem) import OpenSwiftUICore @@ -11,7 +12,8 @@ import AppKit import OpenSwiftUISymbolDualTestsSupport import COpenSwiftUI -// TODO +// MARK: - NSViewPlatformViewDefinition [TODO] + final class NSViewPlatformViewDefinition: PlatformViewDefinition, @unchecked Sendable { override final class var system: PlatformViewDefinition.System { .nsView } @@ -64,6 +66,14 @@ final class NSViewPlatformViewDefinition: PlatformViewDefinition, @unchecked Sen Self.initView(view as! NSView, kind: kind) } + override class func setProjectionTransform(_ transform: ProjectionTransform, projectionView: AnyObject) { + guard let view = projectionView as? _NSProjectionView else { + return + } + view.projectionTransform = transform + view.layer?.transform = .init(transform) + } + override class func setAllowsWindowActivationEvents(_ value: Bool?, for view: AnyObject) { _openSwiftUIUnimplementedWarning() } @@ -72,4 +82,28 @@ final class NSViewPlatformViewDefinition: PlatformViewDefinition, @unchecked Sen _openSwiftUIUnimplementedWarning() } } + +// MARK: - _NSProjectionView [6.5.4] + +@objc +private class _NSProjectionView: _NSInheritedView { + var projectionTransform: ProjectionTransform + + override init(frame frameRect: NSRect) { + projectionTransform = .init() + super.init(frame: frameRect) + } + + required init?(coder: NSCoder) { + projectionTransform = .init() + super.init(coder: coder) + } + + override var wantsUpdateLayer: Bool { true } + + override func _updateLayerGeometryFromView() { + super._updateLayerGeometryFromView() + layer?.transform = .init(projectionTransform) + } +} #endif diff --git a/Sources/OpenSwiftUI/Integration/Render/UIKit/UIViewPlatformViewDefinition.swift b/Sources/OpenSwiftUI/Integration/Render/UIKit/UIViewPlatformViewDefinition.swift index 124b9ec1e..7bccfce0c 100644 --- a/Sources/OpenSwiftUI/Integration/Render/UIKit/UIViewPlatformViewDefinition.swift +++ b/Sources/OpenSwiftUI/Integration/Render/UIKit/UIViewPlatformViewDefinition.swift @@ -2,7 +2,7 @@ // UIViewPlatformViewDefinition.swift // OpenSwiftUI // -// Audited for iOS 18.0 +// Audited for 6.0.87 // Status: WIP // ID: A34643117F00277B93DEBAB70EC06971 (SwiftUI?) @@ -10,8 +10,10 @@ @_spi(DisplayList_ViewSystem) import OpenSwiftUICore import UIKit import OpenSwiftUISymbolDualTestsSupport +import OpenSwiftUI_SPI + +// MARK: - UIViewPlatformViewDefinition [TODO] -// TODO final class UIViewPlatformViewDefinition: PlatformViewDefinition, @unchecked Sendable { override final class var system: PlatformViewDefinition.System { .uiView } @@ -58,5 +60,10 @@ final class UIViewPlatformViewDefinition: PlatformViewDefinition, @unchecked Sen override class func makePlatformView(view: AnyObject, kind: PlatformViewDefinition.ViewKind) { Self.initView(view as! UIView, kind: kind) } + + override class func setProjectionTransform(_ transform: ProjectionTransform, projectionView: AnyObject) { + let layer = CoreViewLayer(system: .uiView, view: projectionView) + layer.transform = .init(transform) + } } #endif diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift index bc00cf97b..41e61c3eb 100644 --- a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift @@ -213,8 +213,8 @@ extension DisplayList { package enum Transform { case affine(CGAffineTransform) case projection(ProjectionTransform) - // case rotation(_RotationEffect.Data) - // case rotation3D(_Rotation3DEffect.Data) + case rotation(_RotationEffect.Data) + case rotation3D(_Rotation3DEffect.Data) } // package typealias AnyEffectAnimation = _DisplayList_AnyEffectAnimation diff --git a/Sources/OpenSwiftUICore/Render/GeometryEffect/GeometryEffect.swift b/Sources/OpenSwiftUICore/Render/GeometryEffect/GeometryEffect.swift index fe145f161..3e44b96ab 100644 --- a/Sources/OpenSwiftUICore/Render/GeometryEffect/GeometryEffect.swift +++ b/Sources/OpenSwiftUICore/Render/GeometryEffect/GeometryEffect.swift @@ -2,13 +2,14 @@ // GeometryEffect.swift // OpenSwiftUICore // -// Status: WIP +// Audited for 6.5.4 +// Status: Complete // ID: 9ED0B9F1F6CE74691B78276C750FEDD3 (SwiftUICore) public import Foundation package import OpenGraphShims -// MARK: - GeometryEffect [6.5.4] [WIP] +// MARK: - GeometryEffect /// An effect that changes the visual appearance of a view, largely without /// changing its ancestors or descendants. @@ -48,10 +49,20 @@ extension GeometryEffect { inputs: _ViewInputs, body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs ) -> _ViewOutputs { - if modifier is _GraphValue<_RotationEffect> { - _openSwiftUIUnimplementedFailure() - } else if modifier is _GraphValue<_Rotation3DEffect> { - _openSwiftUIUnimplementedFailure() + if let modifier = modifier as? _GraphValue<_RotationEffect> { + _RotationEffect + ._makeGeometryEffect( + modifier: modifier, + inputs: inputs, + body: body + ) + } else if let modifier = modifier as? _GraphValue<_Rotation3DEffect> { + _Rotation3DEffect + ._makeGeometryEffect( + modifier: modifier, + inputs: inputs, + body: body + ) } else { DefaultGeometryEffectProvider ._makeGeometryEffect( @@ -79,7 +90,7 @@ extension GeometryEffect { } } -// MARK: - GeometryEffectProvider [6.5.4] +// MARK: - GeometryEffectProvider protocol GeometryEffectProvider { associatedtype Effect: GeometryEffect @@ -147,8 +158,7 @@ extension GeometryEffectProvider { } } - -// MARK: - RoundedSize [6.5.4] +// MARK: - RoundedSize package struct RoundedSize: Rule, AsyncAttribute { @Attribute var position: CGPoint @@ -174,7 +184,7 @@ package struct RoundedSize: Rule, AsyncAttribute { } } -// MARK: - DefaultGeometryEffectProvider [6.5.4] +// MARK: - DefaultGeometryEffectProvider struct DefaultGeometryEffectProvider: GeometryEffectProvider where Effect: GeometryEffect { static func resolve( @@ -206,7 +216,37 @@ struct DefaultGeometryEffectProvider: GeometryEffectProvider where Effec } } -// MARK: - GeometryEffectDisplayList [6.5.4] +// MARK: - Rotation + GeometryEffectProvider + +extension _RotationEffect: GeometryEffectProvider { + typealias Effect = Self + + static func resolve( + effect: _RotationEffect, + origin: inout CGPoint, + size: CGSize, + layoutDirection: LayoutDirection + ) -> DisplayList.Effect { + let data = _RotationEffect.Data(effect, size: size, layoutDirection: layoutDirection) + return .transform(.rotation(data)) + } +} + +extension _Rotation3DEffect: GeometryEffectProvider { + typealias Effect = Self + + static func resolve( + effect: _Rotation3DEffect, + origin: inout CGPoint, + size: CGSize, + layoutDirection: LayoutDirection + ) -> DisplayList.Effect { + let data = _Rotation3DEffect.Data(effect, size: size, layoutDirection: layoutDirection) + return .transform(.rotation3D(data)) + } +} + +// MARK: - GeometryEffectDisplayList private struct GeometryEffectDisplayList: Rule, AsyncAttribute, CustomStringConvertible where Provider: GeometryEffectProvider { @@ -246,7 +286,7 @@ private struct GeometryEffectDisplayList: Rule, AsyncAttribute, Custom } } -// MARK: - GeometryEffectTransform [6.5.4] +// MARK: - GeometryEffectTransform private struct GeometryEffectTransform: Rule, AsyncAttribute where Effect: GeometryEffect { @Attribute var effect: Effect diff --git a/Sources/OpenSwiftUICore/Render/GeometryEffect/OffsetEffect.swift b/Sources/OpenSwiftUICore/Render/GeometryEffect/OffsetEffect.swift index 5f03cf8dc..4e4276547 100644 --- a/Sources/OpenSwiftUICore/Render/GeometryEffect/OffsetEffect.swift +++ b/Sources/OpenSwiftUICore/Render/GeometryEffect/OffsetEffect.swift @@ -5,8 +5,7 @@ // Status: Complete // ID: 72FB21917F353796516DFC9915156779 (SwiftUICore) -import CoreGraphicsShims -public import Foundation +public import CoreGraphicsShims import OpenGraphShims /// Allows you to redefine origin of the child within its coordinate diff --git a/Sources/OpenSwiftUICore/Render/GeometryEffect/Rotation3DEffect.swift b/Sources/OpenSwiftUICore/Render/GeometryEffect/Rotation3DEffect.swift index 115c83c27..7c5002172 100644 --- a/Sources/OpenSwiftUICore/Render/GeometryEffect/Rotation3DEffect.swift +++ b/Sources/OpenSwiftUICore/Render/GeometryEffect/Rotation3DEffect.swift @@ -1,7 +1,245 @@ -import Foundation +// +// Rotation3DEffect.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete -struct _Rotation3DEffect: GeometryEffect { - func effectValue(size: CGSize) -> ProjectionTransform { - .init() +public import CoreGraphicsShims + +// MARK: - RotationEffect + +@available(OpenSwiftUI_v1_0, *) +@frozen +public struct _Rotation3DEffect: GeometryEffect, Equatable { + public var angle: Angle + + public var axis: (x: CGFloat, y: CGFloat, z: CGFloat) + + public var anchor: UnitPoint + + public var anchorZ: CGFloat + + public var perspective: CGFloat + + @_alwaysEmitIntoClient + public init( + angle: Angle, + axis: (x: CGFloat, y: CGFloat, z: CGFloat), + anchor: UnitPoint = .center, + anchorZ: CGFloat = 0, + perspective: CGFloat = 1 + ) { + self.angle = angle + self.axis = axis + self.anchor = anchor + self.anchorZ = anchorZ + self.perspective = perspective + } + + package struct Data { + package var angle: Angle + package var axis: (x: CGFloat, y: CGFloat, z: CGFloat) + package var anchor: (x: CGFloat, y: CGFloat, z: CGFloat) + package var perspective: CGFloat + package var flipWidth: CGFloat + + package init() { + angle = .zero + axis = (.zero, .zero, .zero) + anchor = (.zero, .zero, .zero) + perspective = .zero + flipWidth = .nan + } + + package init(_ effect: _Rotation3DEffect, size: CGSize, layoutDirection: LayoutDirection = .leftToRight) { + let m = max(size.width, size.height) + angle = effect.angle + axis = effect.axis + let f = layoutDirection == .rightToLeft ? size.width : .nan + let s = effect.anchor.in(size) + anchor = (s.x, s.y, effect.anchorZ) + perspective = m / effect.perspective + flipWidth = f + } + + package var transform: ProjectionTransform { + let i = CATransform3DIdentity + let t1 = CATransform3DTranslate(i, anchor.x, anchor.y, anchor.z) + var p = i + p.m34 = -1 / perspective + let r = CATransform3DRotate(CATransform3DConcat(p, t1), angle.radians, axis.x, axis.y, axis.z) + let t2 = CATransform3DTranslate(r, -anchor.x, -anchor.y, -anchor.z) + var transform = ProjectionTransform(t2) + if flipWidth.isFinite { + let base = ProjectionTransform( + m11: -1, m12: 0, m13: 0, + m21: 0, m22: 1, m23: 0, + m31: flipWidth, m32: 0, m33: 1 + ) + transform = base.concatenating(transform).concatenating(base) + } + return transform + } + } + + public func effectValue(size: CGSize) -> ProjectionTransform { + let data = Data(self, size: size) + return data.transform + } + + public typealias AnimatableData = AnimatablePair< + Angle.AnimatableData, + AnimatablePair< + CGFloat, + AnimatablePair< + CGFloat, + AnimatablePair< + CGFloat, + AnimatablePair< + UnitPoint.AnimatableData, + AnimatablePair< + CGFloat, + CGFloat + > + > + > + > + > + > + + public var animatableData: _Rotation3DEffect.AnimatableData { + get { + .init( + angle.animatableData, + .init( + axis.x.animatableData, + .init( + axis.y.animatableData, + .init( + axis.z.animatableData, + .init( + anchor.animatableData, + .init( + anchorZ.animatableData, + perspective.animatableData + ) + ) + ) + ) + ) + ) + } + set { + angle.animatableData = newValue.first + axis.x.animatableData = newValue.second.first + axis.y.animatableData = newValue.second.second.first + axis.z.animatableData = newValue.second.second.second.first + anchor.animatableData = newValue.second.second.second.second.first + anchorZ.animatableData = newValue.second.second.second.second.second.first + perspective.animatableData = newValue.second.second.second.second.second.second + } + } + + public static func == (lhs: _Rotation3DEffect, rhs: _Rotation3DEffect) -> Bool { + lhs.angle == rhs.angle && + lhs.axis == rhs.axis && + lhs.anchor == rhs.anchor && + lhs.anchorZ == rhs.anchorZ && + lhs.perspective == rhs.perspective + } +} + +@available(OpenSwiftUI_v1_0, *) +extension View { + /// Renders a view's content as if it's rotated in three dimensions around + /// the specified axis. + /// + /// Use this method to create the effect of rotating a view in three + /// dimensions around a specified axis of rotation. The modifier projects + /// the rotated content onto the original view's plane. Use the + /// `perspective` value to control the renderer's vanishing point. The + /// following example creates the appearance of rotating text 45˚ about + /// the y-axis: + /// + /// Text("Rotation by passing an angle in degrees") + /// .rotation3DEffect( + /// .degrees(45), + /// axis: (x: 0.0, y: 1.0, z: 0.0), + /// anchor: .center, + /// anchorZ: 0, + /// perspective: 1) + /// .border(Color.gray) + /// + /// ![A screenshot of text in a grey box. The text says Rotation by passing an angle in degrees. The text is rendered in a way that makes it appear farther from the viewer on the right side and closer on the left, as if the text is angled to face someone sitting on the viewer's right.](OpenSwiftUI-View-rotation3DEffect) + /// + /// > Important: In visionOS, create this effect with + /// ``perspectiveRotationEffect(_:axis:anchor:anchorZ:perspective:)`` + /// instead. To truly rotate a view in three dimensions, + /// use a 3D rotation modifier without a perspective input like + /// ``rotation3DEffect(_:axis:anchor:)``. + /// + /// - Parameters: + /// - angle: The angle by which to rotate the view's content. + /// - axis: The axis of rotation, specified as a tuple with named + /// elements for each of the three spatial dimensions. + /// - anchor: A two dimensional unit point within the view about which to + /// perform the rotation. The default value is ``UnitPoint/center``. + /// - anchorZ: The location on the z-axis around which to rotate the + /// content. The default is `0`. + /// - perspective: The relative vanishing point for the rotation. The + /// default is `1`. + /// - Returns: A view with rotated content. + @inlinable + nonisolated public func rotation3DEffect( + _ angle: Angle, + axis: (x: CGFloat, y: CGFloat, z: CGFloat), + anchor: UnitPoint = .center, + anchorZ: CGFloat = 0, + perspective: CGFloat = 1 + ) -> some View { + modifier( + _Rotation3DEffect( + angle: angle, axis: axis, anchor: anchor, anchorZ: anchorZ, + perspective: perspective + ) + ) + } +} + +extension _Rotation3DEffect.Data: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + encoder.doubleField(1, angle.radians) + encoder.floatField(2, Float(axis.x)) + encoder.floatField(3, Float(axis.y)) + encoder.floatField(4, Float(axis.z)) + encoder.cgFloatField(5, anchor.x) + encoder.cgFloatField(6, anchor.y) + encoder.cgFloatField(7, anchor.z) + encoder.cgFloatField(8, perspective) + var flipWidth = flipWidth + if flipWidth.isInfinite || flipWidth.isNaN { + flipWidth = .zero + } + encoder.cgFloatField(9, flipWidth) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var data = _Rotation3DEffect.Data() + while let field = try decoder.nextField() { + switch field.tag { + case 1: data.angle = try .init(radians: decoder.doubleField(field)) + case 2: data.axis.x = try CGFloat(decoder.floatField(field)) + case 3: data.axis.y = try CGFloat(decoder.floatField(field)) + case 4: data.axis.z = try CGFloat(decoder.floatField(field)) + case 5: data.anchor.x = try decoder.cgFloatField(field) + case 6: data.anchor.y = try decoder.cgFloatField(field) + case 7: data.anchor.z = try decoder.cgFloatField(field) + case 8: data.perspective = try decoder.cgFloatField(field) + case 9: data.flipWidth = try decoder.cgFloatField(field) + default: try decoder.skipField(field) + } + } + self = data } } diff --git a/Sources/OpenSwiftUICore/Render/GeometryEffect/RotationEffect.swift b/Sources/OpenSwiftUICore/Render/GeometryEffect/RotationEffect.swift index 43ed2c895..4fd397569 100644 --- a/Sources/OpenSwiftUICore/Render/GeometryEffect/RotationEffect.swift +++ b/Sources/OpenSwiftUICore/Render/GeometryEffect/RotationEffect.swift @@ -1,7 +1,134 @@ -import Foundation +// +// RotationEffect.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete -struct _RotationEffect: GeometryEffect { - func effectValue(size: CGSize) -> ProjectionTransform { - .init() +public import CoreGraphicsShims + +// MARK: - RotationEffect + +@available(OpenSwiftUI_v1_0, *) +@frozen +public struct _RotationEffect: GeometryEffect, Equatable { + + public var angle: Angle + + public var anchor: UnitPoint + + @inlinable + nonisolated public init(angle: Angle, anchor: UnitPoint = .center) { + self.angle = angle + self.anchor = anchor + } + + package struct Data { + package var angle: Angle + + package var anchor: CGPoint + + package init() { + angle = .zero + anchor = .zero + } + + package init(_ effect: _RotationEffect, size: CGSize, layoutDirection: LayoutDirection = .leftToRight) { + var s = effect.anchor.in(size) + var a = effect.angle + if layoutDirection == .rightToLeft { + s.x = size.width - s.x + a.negate() + } + angle = a + anchor = s + } + + package var transform: CGAffineTransform { + CGAffineTransform(translationX: anchor.x, y: anchor.y) + .rotated(by: angle) + .translatedBy(x: -anchor.x, y: -anchor.y) + } + } + + public func effectValue(size: CGSize) -> ProjectionTransform { + let data = Data(self, size: size) + return .init(data.transform) + } + + public typealias AnimatableData = AnimatablePair + + public var animatableData: _RotationEffect.AnimatableData { + get { .init(angle.animatableData, anchor.animatableData) } + set { + angle.animatableData = newValue.first + anchor.animatableData = newValue.second + } + } +} + +@available(OpenSwiftUI_v1_0, *) +extension View { + /// Rotates a view's rendered output in two dimensions around the specified + /// point. + /// + /// This modifier rotates the view's content around the axis that points + /// out of the xy-plane. It has no effect on the view's frame. + /// The following code rotates text by 22˚ and then draws a border around + /// the modified view to show that the frame remains unchanged by the + /// rotation modifier: + /// + /// Text("Rotation by passing an angle in degrees") + /// .rotationEffect(.degrees(22)) + /// .border(Color.gray) + /// + /// ![A screenshot of text and a wide grey box. The text says Rotation by passing an angle in degrees. The baseline of the text is rotated clockwise by 22 degrees relative to the box. The center of the box and the center of the text are aligned.](OpenSwiftUI-View-rotationEffect) + /// + /// - Parameters: + /// - angle: The angle by which to rotate the view. + /// - anchor: A unit point within the view about which to + /// perform the rotation. The default value is ``UnitPoint/center``. + /// - Returns: A view with rotated content. + @inlinable + nonisolated public func rotationEffect(_ angle: Angle, anchor: UnitPoint = .center) -> some View { + modifier(_RotationEffect(angle: angle, anchor: anchor)) + } +} + +extension _RotationEffect: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + encoder.doubleField(1, angle.radians) + try encoder.messageField(2, anchor, defaultValue: .center) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var effect = _RotationEffect(angle: .zero, anchor: .center) + while let field = try decoder.nextField() { + switch field.tag { + case 1: effect.angle = try .init(radians: decoder.doubleField(field)) + case 2: effect.anchor = try decoder.messageField(field) + default: try decoder.skipField(field) + } + } + self = effect + } +} + +extension _RotationEffect.Data: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + encoder.doubleField(1, angle.radians) + try encoder.messageField(2, anchor) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var data = _RotationEffect.Data() + while let field = try decoder.nextField() { + switch field.tag { + case 1: data.angle = try .init(radians: decoder.doubleField(field)) + case 2: data.anchor = try decoder.messageField(field) + default: try decoder.skipField(field) + } + } + self = data } } diff --git a/Sources/OpenSwiftUISymbolDualTestsSupport/Render/GeometryEffect/Rotation3DEffect+TestSub.c b/Sources/OpenSwiftUISymbolDualTestsSupport/Render/GeometryEffect/Rotation3DEffect+TestSub.c new file mode 100644 index 000000000..c44577a02 --- /dev/null +++ b/Sources/OpenSwiftUISymbolDualTestsSupport/Render/GeometryEffect/Rotation3DEffect+TestSub.c @@ -0,0 +1,16 @@ +// +// Rotation3DEffect+TestSub.c +// OpenSwiftUISymbolDualTestsSupport + +#include "OpenSwiftUIBase.h" + +#if OPENSWIFTUI_TARGET_OS_DARWIN +#import + +DEFINE_SL_STUB_SLF(OpenSwiftUITestStub__Rotation3DEffectDataInit, SwiftUI, $s7SwiftUI17_Rotation3DEffectV4DataVAEycfC); +DEFINE_SL_STUB_SLF(OpenSwiftUITestStub__Rotation3DEffectDataInitEffect, SwiftUI, $s7SwiftUI17_Rotation3DEffectV4DataV_4size15layoutDirectionAeC_So6CGSizeVAA06LayoutH0OtcfC); +DEFINE_SL_STUB_SLF(OpenSwiftUITestStub__Rotation3DEffectDataTransform, SwiftUI, $s7SwiftUI17_Rotation3DEffectV4DataV9transformAA19ProjectionTransformVvg); + +DEFINE_SL_STUB_SLF(OpenSwiftUITestStub__Rotation3DEffectDataEncode, SwiftUI, $s7SwiftUI17_Rotation3DEffectV4DataVAAE6encode2toyAA15ProtobufEncoderVz_tKF); +DEFINE_SL_STUB_SLF(OpenSwiftUITestStub__Rotation3DEffectDataDecode, SwiftUI, $s7SwiftUI17_Rotation3DEffectV4DataVAAE4fromAeA15ProtobufDecoderVz_tKcfC); +#endif diff --git a/Tests/OpenSwiftUICoreTests/Data/Protobuf/ProtobufTestHelper.swift b/Sources/OpenSwiftUITestsSupport/Data/Protobuf/ProtobufTestHelper.swift similarity index 64% rename from Tests/OpenSwiftUICoreTests/Data/Protobuf/ProtobufTestHelper.swift rename to Sources/OpenSwiftUITestsSupport/Data/Protobuf/ProtobufTestHelper.swift index bc3b09148..2e8a96ccb 100644 --- a/Tests/OpenSwiftUICoreTests/Data/Protobuf/ProtobufTestHelper.swift +++ b/Sources/OpenSwiftUITestsSupport/Data/Protobuf/ProtobufTestHelper.swift @@ -2,23 +2,23 @@ // ProtobufTestHelper.swift // OpenSwiftUICoreTests -import Foundation -import OpenSwiftUICore +package import Foundation +package import OpenSwiftUICore // MARK: - Message Types -struct BoolMessage: ProtobufMessage, Equatable { - var value: Bool - - init() { +package struct BoolMessage: ProtobufMessage, Equatable { + package var value: Bool + + package init() { self.value = false } - init(value: Bool) { + package init(value: Bool) { self.value = value } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { while let field = try decoder.nextField() { switch field.tag { case 1: @@ -31,29 +31,29 @@ struct BoolMessage: ProtobufMessage, Equatable { value = false } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { encoder.boolField(1, value) } } -struct EnumMessage: ProtobufMessage { - enum Value: UInt, ProtobufEnum { - var protobufValue: UInt { rawValue } +package struct EnumMessage: ProtobufMessage { + package enum Value: UInt, ProtobufEnum { + package var protobufValue: UInt { rawValue } - init?(protobufValue: UInt) { + package init?(protobufValue: UInt) { self.init(rawValue: protobufValue) } case a, b } - var value: Value + package var value: Value - init(value: Value) { + package init(value: Value) { self.value = value } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { while let field = try decoder.nextField() { switch field.tag { case 1: @@ -66,30 +66,30 @@ struct EnumMessage: ProtobufMessage { throw ProtobufDecoder.DecodingError.failed } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { encoder.enumField(1, value) } } -struct EnumEquatableMessage: ProtobufMessage { - enum Value: UInt, ProtobufEnum, Equatable { - var protobufValue: UInt { rawValue } +package struct EnumEquatableMessage: ProtobufMessage { + package enum Value: UInt, ProtobufEnum, Equatable { + package var protobufValue: UInt { rawValue } - init?(protobufValue: UInt) { + package init?(protobufValue: UInt) { self.init(rawValue: protobufValue) } case a, b } - var value: Value - static let defaultValue: Value = .a + package var value: Value + package static let defaultValue: Value = .a - init(value: Value) { + package init(value: Value) { self.value = value } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { while let field = try decoder.nextField() { switch field.tag { case 1: @@ -102,20 +102,20 @@ struct EnumEquatableMessage: ProtobufMessage { value = Self.defaultValue } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { encoder.enumField(1, value, defaultValue: Self.defaultValue) } } -struct IntegerMessage: ProtobufMessage, Equatable { - var intValue: Int? - var unsignedIntValue: UInt? - var int64Value: Int64? - var unsignedInt64Value: UInt64? - var int32Value: Int32? - var unsignedInt32Value: UInt32? - - init(intValue: Int? = nil, unsignedIntValue: UInt? = nil, int64Value: Int64? = nil, unsignedInt64Value: UInt64? = nil, int32Value: Int32? = nil, unsignedInt32Value: UInt32? = nil) { +package struct IntegerMessage: ProtobufMessage, Equatable { + package var intValue: Int? + package var unsignedIntValue: UInt? + package var int64Value: Int64? + package var unsignedInt64Value: UInt64? + package var int32Value: Int32? + package var unsignedInt32Value: UInt32? + + package init(intValue: Int? = nil, unsignedIntValue: UInt? = nil, int64Value: Int64? = nil, unsignedInt64Value: UInt64? = nil, int32Value: Int32? = nil, unsignedInt32Value: UInt32? = nil) { self.intValue = intValue self.unsignedIntValue = unsignedIntValue self.int64Value = int64Value @@ -124,7 +124,7 @@ struct IntegerMessage: ProtobufMessage, Equatable { self.unsignedInt32Value = unsignedInt32Value } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { while let field = try decoder.nextField() { switch field.tag { case 1: @@ -145,7 +145,7 @@ struct IntegerMessage: ProtobufMessage, Equatable { } } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { if let intValue = intValue { encoder.intField(1, intValue) } @@ -167,18 +167,18 @@ struct IntegerMessage: ProtobufMessage, Equatable { } } -struct FloatPointMessage: ProtobufMessage, Equatable { - var float: Float? - var double: Double? - var cgFloat: CGFloat? +package struct FloatPointMessage: ProtobufMessage, Equatable { + package var float: Float? + package var double: Double? + package var cgFloat: CGFloat? - init(float: Float? = nil, double: Double? = nil, cgFloat: CGFloat? = nil) { + package init(float: Float? = nil, double: Double? = nil, cgFloat: CGFloat? = nil) { self.float = float self.double = double self.cgFloat = cgFloat } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { while let field = try decoder.nextField() { switch field.tag { case 1: float = try decoder.floatField(field) @@ -190,7 +190,7 @@ struct FloatPointMessage: ProtobufMessage, Equatable { } } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { if let float { encoder.floatField(1, float) } @@ -203,14 +203,14 @@ struct FloatPointMessage: ProtobufMessage, Equatable { } } -struct DataMessage: ProtobufMessage, Equatable { - var data: Data? +package struct DataMessage: ProtobufMessage, Equatable { + package var data: Data? - init(data: Data? = nil) { + package init(data: Data? = nil) { self.data = data } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { while let field = try decoder.nextField() { switch field.tag { case 1: @@ -223,21 +223,21 @@ struct DataMessage: ProtobufMessage, Equatable { data = nil } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { if let data { encoder.dataField(1, data) } } } -struct PackedIntMessage: ProtobufMessage { - var values: [Int] +package struct PackedIntMessage: ProtobufMessage { + package var values: [Int] - init(values: [Int]) { + package init(values: [Int]) { self.values = values } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { var values: [Int] = [] while true { guard let field = try decoder.nextField() else { @@ -253,21 +253,21 @@ struct PackedIntMessage: ProtobufMessage { } } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { encoder.packedField(1) { encoder in values.forEach { encoder.encodeVarintZZ($0) } } } } -struct MessageMessage: ProtobufMessage where T: ProtobufMessage { - var value: T +package struct MessageMessage: ProtobufMessage where T: ProtobufMessage { + package var value: T - init(value: T) { + package init(value: T) { self.value = value } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { while let field = try decoder.nextField() { switch field.tag { case 1: @@ -280,25 +280,25 @@ struct MessageMessage: ProtobufMessage where T: ProtobufMessage { throw ProtobufDecoder.DecodingError.failed } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { try encoder.messageField(1, value) } } -protocol Defaultable { +package protocol Defaultable { init() } extension BoolMessage: Defaultable {} -struct EquatableMessageMessage: ProtobufMessage where T: ProtobufMessage, T: Equatable, T: Defaultable { - var value: T +package struct EquatableMessageMessage: ProtobufMessage where T: ProtobufMessage, T: Equatable, T: Defaultable { + package var value: T - init(value: T) { + package init(value: T) { self.value = value } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { while let field = try decoder.nextField() { switch field.tag { case 1: @@ -311,19 +311,19 @@ struct EquatableMessageMessage: ProtobufMessage where T: ProtobufMessage, T: value = T() } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { try encoder.messageField(1, value, defaultValue: .init()) } } -struct StringMessage: ProtobufMessage, Equatable { - var string: String +package struct StringMessage: ProtobufMessage, Equatable { + package var string: String - init(string: String) { + package init(string: String) { self.string = string } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { while let field = try decoder.nextField() { switch field.tag { case 1: @@ -336,19 +336,19 @@ struct StringMessage: ProtobufMessage, Equatable { throw ProtobufDecoder.DecodingError.failed } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { try encoder.stringField(1, string) } } -struct CodableMessage: ProtobufMessage where T: Codable { - var value: T +package struct CodableMessage: ProtobufMessage where T: Codable { + package var value: T - init(value: T) { + package init(value: T) { self.value = value } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { while let field = try decoder.nextField() { switch field.tag { case 1: @@ -361,35 +361,35 @@ struct CodableMessage: ProtobufMessage where T: Codable { throw ProtobufDecoder.DecodingError.failed } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { try encoder.codableField(1, value) } } -struct EquatableCodableMessage: ProtobufMessage where T: Codable, T: Equatable { - var value: T - let defaultValue: T +package struct EquatableCodableMessage: ProtobufMessage where T: Codable, T: Equatable { + package var value: T + package let defaultValue: T - init(value: T, defaultValue: T) { + package init(value: T, defaultValue: T) { self.value = value self.defaultValue = defaultValue } - init(from decoder: inout ProtobufDecoder) throws { + package init(from decoder: inout ProtobufDecoder) throws { _openSwiftUIUnimplementedFailure() } - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { try encoder.codableField(1, value, defaultValue: defaultValue) } } -struct EmptyMessage: ProtobufMessage { - init() {} +package struct EmptyMessage: ProtobufMessage { + package init() {} - init(from decoder: inout ProtobufDecoder) throws {} + package init(from decoder: inout ProtobufDecoder) throws {} - func encode(to encoder: inout ProtobufEncoder) throws { + package func encode(to encoder: inout ProtobufEncoder) throws { encoder.emptyField(1) } } @@ -397,7 +397,7 @@ struct EmptyMessage: ProtobufMessage { // MARK: - Data + Extension extension Data { - init?(hexString: String) { + package init?(hexString: String) { let hex = hexString.count % 2 == 0 ? hexString : "0" + hexString var index = hex.startIndex var bytes: [UInt8] = [] @@ -414,7 +414,7 @@ extension Data { self.init(bytes) } - var hexString: String { + package var hexString: String { map { String(format: "%02x", $0) }.joined() } } @@ -422,7 +422,7 @@ extension Data { // MARK: - String + Extension extension String { - func decodePBHexString(_ type: T.Type = T.self) throws -> T where T: ProtobufDecodableMessage { + package func decodePBHexString(_ type: T.Type = T.self) throws -> T where T: ProtobufDecodableMessage { guard let data = Data(hexString: self) else { throw ProtobufDecoder.DecodingError.failed } @@ -434,7 +434,7 @@ extension String { // MARK: - ProtobufEncodableMessage + Extension extension ProtobufEncodableMessage { - var pbHexString: String { + package var pbHexString: String { get throws { try ProtobufEncoder.encoding(self).hexString } @@ -447,14 +447,14 @@ extension ProtobufEncodableMessage { import Testing extension ProtobufEncodableMessage { - func testPBEncoding(hexString expectedHexString: String) throws { + package func testPBEncoding(hexString expectedHexString: String) throws { let data = try ProtobufEncoder.encoding(self) #expect(data.hexString == expectedHexString) } } extension ProtobufDecodableMessage where Self: Equatable { - func testPBDecoding(hexString: String) throws { + package func testPBDecoding(hexString: String) throws { let decodedValue = try hexString.decodePBHexString(Self.self) #expect(decodedValue == self) } diff --git a/Sources/OpenSwiftUITestsSupport/Layout/Geometry/ProjectionTransformHelper.swift b/Sources/OpenSwiftUITestsSupport/Layout/Geometry/ProjectionTransformHelper.swift new file mode 100644 index 000000000..0b9cc3bc2 --- /dev/null +++ b/Sources/OpenSwiftUITestsSupport/Layout/Geometry/ProjectionTransformHelper.swift @@ -0,0 +1,31 @@ +// +// ProjectionTransformHelper.swift +// OpenSwiftUITestsSupport + +public import OpenSwiftUICore + +extension ProjectionTransform: Hashable { + public func hash(into hasher: inout Hasher) { + m11.hash(into: &hasher) + m12.hash(into: &hasher) + m13.hash(into: &hasher) + m21.hash(into: &hasher) + m22.hash(into: &hasher) + m23.hash(into: &hasher) + m31.hash(into: &hasher) + m32.hash(into: &hasher) + m33.hash(into: &hasher) + } + + package func isAlmostEqual(to other: ProjectionTransform) -> Bool { + m11.isAlmostEqual(to: other.m11) && + m12.isAlmostEqual(to: other.m12) && + m13.isAlmostEqual(to: other.m13) && + m21.isAlmostEqual(to: other.m21) && + m22.isAlmostEqual(to: other.m22) && + m23.isAlmostEqual(to: other.m23) && + m31.isAlmostEqual(to: other.m31) && + m32.isAlmostEqual(to: other.m32) && + m33.isAlmostEqual(to: other.m33) + } +} diff --git a/Sources/OpenSwiftUITestsSupport/Render/GeometryEffect/Rotation3DEffectHelper.swift b/Sources/OpenSwiftUITestsSupport/Render/GeometryEffect/Rotation3DEffectHelper.swift new file mode 100644 index 000000000..8d9f085be --- /dev/null +++ b/Sources/OpenSwiftUITestsSupport/Render/GeometryEffect/Rotation3DEffectHelper.swift @@ -0,0 +1,56 @@ +// +// Rotation3DEffectHelper.swift +// OpenSwiftUITestsSupport + +package import Foundation +package import OpenSwiftUICore + +extension _Rotation3DEffect.Data: Hashable { + package static func == (lhs: _Rotation3DEffect.Data, rhs: _Rotation3DEffect.Data) -> Bool { + lhs.angle == rhs.angle && + lhs.axis == rhs.axis && + lhs.anchor == rhs.anchor && + lhs.perspective == rhs.perspective && + (lhs.flipWidth.isNaN && rhs.flipWidth.isNaN || lhs.flipWidth == rhs.flipWidth) + } + + package func hash(into hasher: inout Hasher) { + angle.hash(into: &hasher) + axis.x.hash(into: &hasher) + axis.y.hash(into: &hasher) + axis.z.hash(into: &hasher) + anchor.x.hash(into: &hasher) + anchor.y.hash(into: &hasher) + anchor.z.hash(into: &hasher) + perspective.hash(into: &hasher) + flipWidth.hash(into: &hasher) + } + + package func isAlmostEqual(to other: _Rotation3DEffect.Data) -> Bool { + angle.radians.isAlmostEqual(to: other.angle.radians) && + axis.x.isAlmostEqual(to: other.axis.x) && + axis.y.isAlmostEqual(to: other.axis.y) && + axis.z.isAlmostEqual(to: other.axis.z) && + anchor.x.isAlmostEqual(to: other.anchor.x) && + anchor.y.isAlmostEqual(to: other.anchor.y) && + anchor.z.isAlmostEqual(to: other.anchor.z) && + perspective.isAlmostEqual(to: other.perspective) && + (flipWidth.isNaN && other.flipWidth.isNaN || flipWidth.isAlmostEqual(to: other.flipWidth)) + } + + package init( + angle: Angle = .zero, + axis: (x: CGFloat, y: CGFloat, z: CGFloat) = (.zero, .zero, .zero), + anchor: (x: CGFloat, y: CGFloat, z: CGFloat) = (.zero, .zero, .zero), + perspective: CGFloat = .zero, + flipWidth: CGFloat + ) { + var data = _Rotation3DEffect.Data() + data.angle = angle + data.axis = axis + data.anchor = anchor + data.perspective = perspective + data.flipWidth = flipWidth + self = data + } +} diff --git a/Sources/OpenSwiftUI_SPI/Shims/AppKit/AppKit_Private.h b/Sources/OpenSwiftUI_SPI/Shims/AppKit/AppKit_Private.h index 75c242477..67ff290f3 100644 --- a/Sources/OpenSwiftUI_SPI/Shims/AppKit/AppKit_Private.h +++ b/Sources/OpenSwiftUI_SPI/Shims/AppKit/AppKit_Private.h @@ -26,6 +26,10 @@ typedef OPENSWIFTUI_ENUM(NSInteger, NSViewVibrantBlendingStyle) { NSViewVibrantBlendingStyle_1 = 1, }; +@interface NSView () +- (void)_updateLayerGeometryFromView; +@end + OPENSWIFTUI_ASSUME_NONNULL_END #endif /* __has_include() */ diff --git a/Tests/CoreGraphicsShimsTests/CATransform3DTests.swift b/Tests/CoreGraphicsShimsTests/CATransform3DTests.swift index ff07f120c..76afc6a02 100644 --- a/Tests/CoreGraphicsShimsTests/CATransform3DTests.swift +++ b/Tests/CoreGraphicsShimsTests/CATransform3DTests.swift @@ -82,4 +82,44 @@ struct CATransform3DTests { // Should return the same matrix for non-invertible input #expect(CATransform3DEqualToTransform(result, singular)) } + + @Test + func rotationZ90_affineMapsPoint() { + let rot = CATransform3DMakeRotation(CGFloat.pi / 2, 0, 0, 1) + let cg = CATransform3DGetAffineTransform(rot) + let p = CGPoint(x: 1, y: 0) + let res = p.applying(cg) + #expect(res.x.isApproximatelyEqual(to: 0.0, absoluteTolerance: 0.001)) + #expect(res.y.isApproximatelyEqual(to: 1.0, absoluteTolerance: 0.001)) + } + + @Test + func rotationAxisZeroIsIdentity() { + let rot = CATransform3DMakeRotation(1.0, 0, 0, 0) + #expect(CATransform3DEqualToTransform(rot, CATransform3DIdentity)) + } + + @Test + func translateOnIdentityEqualsMakeTranslation() { + let res = CATransform3DTranslate(CATransform3DIdentity, 5, 6, 7) + let expected = CATransform3DMakeTranslation(5, 6, 7) + #expect(CATransform3DEqualToTransform(res, expected)) + } + + @Test + func rotateConcatProperty() { + let angle = CGFloat.pi / 3 + let ax: CGFloat = 1.0 + let ay: CGFloat = 0.5 + let az: CGFloat = -0.25 + let t = CATransform3DMakeTranslation(10, 20, 30) + let result = CATransform3DRotate(t, angle, ax, ay, az) + let expected = CATransform3DConcat(CATransform3DMakeRotation(angle, ax, ay, az), t) + #expect(result.m11.isApproximatelyEqual(to: expected.m11)) + #expect(result.m12.isApproximatelyEqual(to: expected.m12)) + #expect(result.m21.isApproximatelyEqual(to: expected.m21)) + #expect(result.m22.isApproximatelyEqual(to: expected.m22)) + #expect(result.m41.isApproximatelyEqual(to: expected.m41)) + #expect(result.m42.isApproximatelyEqual(to: expected.m42)) + } } diff --git a/Tests/CoreGraphicsShimsTests/CGAffineTransformTests.swift b/Tests/CoreGraphicsShimsTests/CGAffineTransformTests.swift new file mode 100644 index 000000000..223109bb2 --- /dev/null +++ b/Tests/CoreGraphicsShimsTests/CGAffineTransformTests.swift @@ -0,0 +1,91 @@ +import Testing +import CoreGraphicsShims +import Numerics + +@Suite +struct CGAffineTransformTests { + + // MARK: - Identity + + @Test + func identityIsIdentity() { + let t = CGAffineTransform.identity + #expect(t.isIdentity) + } + + // MARK: - Translation + + @Test + func translationAppliesToPoint() { + let t = CGAffineTransform(translationX: 10, y: 20) + let p = CGPoint(x: 1, y: 2) + let res = p.applying(t) + #expect(res.x.isApproximatelyEqual(to: 11.0)) + #expect(res.y.isApproximatelyEqual(to: 22.0)) + } + + @Test + func translationInvert() { + let t = CGAffineTransform(translationX: 10, y: 20) + let inv = t.inverted() + #expect(inv.tx.isApproximatelyEqual(to: -10.0)) + #expect(inv.ty.isApproximatelyEqual(to: -20.0)) + } + + // MARK: - Scale + + @Test + func scaleInvert() { + let s = CGAffineTransform(scaleX: 2, y: 4) + let inv = s.inverted() + #expect(inv.a.isApproximatelyEqual(to: 1.0 / 2.0)) + #expect(inv.d.isApproximatelyEqual(to: 1.0 / 4.0)) + } + + // MARK: - Rotation + + @Test + func rotation90MapsPoint() { + let r = CGAffineTransform(rotationAngle: .pi / 2) + #expect(r.a.isApproximatelyEqual(to: 0.0, absoluteTolerance: 0.001)) + #expect(r.b.isApproximatelyEqual(to: 1.0, absoluteTolerance: + 0.001)) + #expect(r.c.isApproximatelyEqual(to: -1.0, absoluteTolerance: 0.001)) + #expect(r.d.isApproximatelyEqual(to: 0.0, absoluteTolerance: 0.001)) + #expect(r.tx.isApproximatelyEqual(to: 0.0, absoluteTolerance: 0.001)) + #expect(r.ty.isApproximatelyEqual(to: 0.0, absoluteTolerance: 0.001)) + + let p = CGPoint(x: 1, y: 0) + let res = p.applying(r) + #expect(res.x.isApproximatelyEqual(to: 0.0, absoluteTolerance: 0.001)) + #expect(res.y.isApproximatelyEqual(to: 1.0, absoluteTolerance: 0.001)) + } + + // MARK: - Concatenation + + @Test + func concatenationAppliesInCorrectOrder() { + let t = CGAffineTransform(translationX: 5, y: 7) + let s = CGAffineTransform(scaleX: 2, y: 3) + let concatenated = s.concatenating(t) + let p = CGPoint(x: 1, y: 1) + let sequential = p.applying(s).applying(t) + let combined = p.applying(concatenated) + #expect(sequential.x.isApproximatelyEqual(to: combined.x)) + #expect(sequential.y.isApproximatelyEqual(to: combined.y)) + } + + // MARK: - Non-invertible + + @Test + func nonInvertibleReturnsSame() { + var singular = CGAffineTransform.identity + singular.a = 0 + singular.d = 0 + let inv = singular.inverted() + #expect(inv.a.isApproximatelyEqual(to: singular.a)) + #expect(inv.d.isApproximatelyEqual(to: singular.d)) + #expect(inv.tx.isApproximatelyEqual(to: singular.tx)) + #expect(inv.ty.isApproximatelyEqual(to: singular.ty)) + } +} diff --git a/Tests/OpenSwiftUICoreTests/Data/Protobuf/ProtobufDecoderTests.swift b/Tests/OpenSwiftUICoreTests/Data/Protobuf/ProtobufDecoderTests.swift index 6cfa17b3c..587caf829 100644 --- a/Tests/OpenSwiftUICoreTests/Data/Protobuf/ProtobufDecoderTests.swift +++ b/Tests/OpenSwiftUICoreTests/Data/Protobuf/ProtobufDecoderTests.swift @@ -2,9 +2,10 @@ // ProtobufDecoderTests.swift // OpenSwiftUICoreTests +import Foundation import OpenSwiftUICore +import OpenSwiftUITestsSupport import Testing -import Foundation struct ProtobufDecoderTests { @Test diff --git a/Tests/OpenSwiftUICoreTests/Data/Protobuf/ProtobufEncoderTests.swift b/Tests/OpenSwiftUICoreTests/Data/Protobuf/ProtobufEncoderTests.swift index 510cc5454..30619fc85 100644 --- a/Tests/OpenSwiftUICoreTests/Data/Protobuf/ProtobufEncoderTests.swift +++ b/Tests/OpenSwiftUICoreTests/Data/Protobuf/ProtobufEncoderTests.swift @@ -2,9 +2,10 @@ // ProtobufEncoderTests.swift // OpenSwiftUICoreTests +import Foundation import OpenSwiftUICore +import OpenSwiftUITestsSupport import Testing -import Foundation // FIXME: extra () is a workaround for swiftlang/swift-testing#756 struct ProtobufEncoderTests { diff --git a/Tests/OpenSwiftUICoreTests/Layout/Geometry/ProjectionTransformTests.swift b/Tests/OpenSwiftUICoreTests/Layout/Geometry/ProjectionTransformTests.swift index 35d6a0540..1c351355b 100644 --- a/Tests/OpenSwiftUICoreTests/Layout/Geometry/ProjectionTransformTests.swift +++ b/Tests/OpenSwiftUICoreTests/Layout/Geometry/ProjectionTransformTests.swift @@ -14,45 +14,33 @@ struct ProjectionTransformTests { @Test func defaultInit() { let transform = ProjectionTransform() - #expect(transform.m11.isApproximatelyEqual(to: 1.0)) - #expect(transform.m12.isApproximatelyEqual(to: 0.0)) - #expect(transform.m13.isApproximatelyEqual(to: 0.0)) - #expect(transform.m21.isApproximatelyEqual(to: 0.0)) - #expect(transform.m22.isApproximatelyEqual(to: 1.0)) - #expect(transform.m23.isApproximatelyEqual(to: 0.0)) - #expect(transform.m31.isApproximatelyEqual(to: 0.0)) - #expect(transform.m32.isApproximatelyEqual(to: 0.0)) - #expect(transform.m33.isApproximatelyEqual(to: 1.0)) + #expect(transform.isAlmostEqual(to: .init( + m11: 1.0, m12: 0.0, m13: 0.0, + m21: 0.0, m22: 1.0, m23: 0.0, + m31: 0.0, m32: 0.0, m33: 1.0 + ))) } @Test func cgAffineTransformInit() { let affine = CGAffineTransform(a: 2, b: 3, c: 4, d: 5, tx: 6, ty: 7) let transform = ProjectionTransform(affine) - #expect(transform.m11.isApproximatelyEqual(to: 2)) - #expect(transform.m12.isApproximatelyEqual(to: 3)) - #expect(transform.m21.isApproximatelyEqual(to: 4)) - #expect(transform.m22.isApproximatelyEqual(to: 5)) - #expect(transform.m31.isApproximatelyEqual(to: 6)) - #expect(transform.m32.isApproximatelyEqual(to: 7)) - #expect(transform.m13.isApproximatelyEqual(to: 0)) - #expect(transform.m23.isApproximatelyEqual(to: 0)) - #expect(transform.m33.isApproximatelyEqual(to: 1)) + #expect(transform.isAlmostEqual(to: .init( + m11: 2, m12: 3, m13: 0, + m21: 4, m22: 5, m23: 0, + m31: 6, m32: 7, m33: 1 + ))) } @Test func caTransform3DInit() { let t3d = CATransform3DMakeTranslation(1, 2, 3) let transform = ProjectionTransform(t3d) - #expect(transform.m11.isApproximatelyEqual(to: 1)) - #expect(transform.m12.isApproximatelyEqual(to: 0)) - #expect(transform.m13.isApproximatelyEqual(to: t3d.m14)) - #expect(transform.m21.isApproximatelyEqual(to: 0)) - #expect(transform.m22.isApproximatelyEqual(to: 1)) - #expect(transform.m23.isApproximatelyEqual(to: t3d.m24)) - #expect(transform.m31.isApproximatelyEqual(to: 1)) - #expect(transform.m32.isApproximatelyEqual(to: 2)) - #expect(transform.m33.isApproximatelyEqual(to: t3d.m44)) + #expect(transform.isAlmostEqual(to: .init( + m11: 1, m12: 0, m13: t3d.m14, + m21: 0, m22: 1, m23: t3d.m24, + m31: 1, m32: 2, m33: t3d.m44 + ))) } // MARK: - Property Tests diff --git a/Tests/OpenSwiftUICoreTests/Render/GeometryEffect/Rotation3DEffectTests.swift b/Tests/OpenSwiftUICoreTests/Render/GeometryEffect/Rotation3DEffectTests.swift new file mode 100644 index 000000000..d2ca4126b --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Render/GeometryEffect/Rotation3DEffectTests.swift @@ -0,0 +1,166 @@ +// +// Rotation3DEffectTests.swift +// OpenSwiftUICoreTests + +#if canImport(CoreGraphics) +import Foundation +import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +@Suite +struct Rotation3DEffectDualTests { + private static func makeEffect() -> _Rotation3DEffect { + _Rotation3DEffect( + angle: .radians(.pi / 4), + axis: (x: 1, y: 0, z: 0), + anchor: .center, + anchorZ: 2, + perspective: 2, + ) + } + + private static func makeEffect2() -> _Rotation3DEffect { + _Rotation3DEffect( + angle: .radians(.pi / 3), + axis: (x: 0, y: 1, z: 0), + anchor: .center, + anchorZ: 1, + perspective: 1, + ) + } + + @Suite + struct DataTests { + private static func makeData() -> (_Rotation3DEffect.Data, _Rotation3DEffect.Data) { + let effect = makeEffect() + let size = CGSize(width: 100, height: 50) + let leftData = _Rotation3DEffect.Data(effect, size: size, layoutDirection: .leftToRight) + let rightData = _Rotation3DEffect.Data(effect, size: size, layoutDirection: .rightToLeft) + return (leftData, rightData) + } + + private static func makeData2() -> (_Rotation3DEffect.Data, _Rotation3DEffect.Data) { + let effect = makeEffect2() + let size = CGSize(width: 100, height: 50) + let leftData = _Rotation3DEffect.Data(effect, size: size, layoutDirection: .leftToRight) + let rightData = _Rotation3DEffect.Data(effect, size: size, layoutDirection: .rightToLeft) + return (leftData, rightData) + } + + @Test( + arguments: [ + ( + makeData().0, + _Rotation3DEffect.Data( + angle: .radians(.pi / 4), + axis: (x: 1, y: 0, z: 0), + anchor: (x: 50, y: 25, z: 2), + perspective: 50, + flipWidth: .nan + ) + ), + ( + makeData().1, + _Rotation3DEffect.Data( + angle: .radians(.pi / 4), + axis: (x: 1, y: 0, z: 0), + anchor: (x: 50, y: 25, z: 2), + perspective: 50, + flipWidth: 100 + ) + ), + ( + makeData2().0, + _Rotation3DEffect.Data( + angle: .radians(.pi / 3), + axis: (x: 0, y: 1, z: 0), + anchor: (x: 50, y: 25, z: 1), + perspective: 100, + flipWidth: .nan + ) + ), + ( + makeData2().1, + _Rotation3DEffect.Data( + angle: .radians(.pi / 3), + axis: (x: 0, y: 1, z: 0), + anchor: (x: 50, y: 25, z: 1), + perspective: 100, + flipWidth: 100 + ) + ), + ] + ) + func dataInit(_ data: _Rotation3DEffect.Data, expected: _Rotation3DEffect.Data) { + #expect(data.isAlmostEqual(to: expected)) + } + + @Test( + arguments: [ + ( + makeData().0, + ProjectionTransform( + m11: 1.0, m12: 0.0, m13: 0.0, + m21: -0.7071067811865475, m22: 0.35355339059327384, m23: -0.014142135623730949, + m31: 19.09188309203678, m32: 18.282485578727798, m33: 1.3818376618407355 + ) + ), + ( + makeData().1, + ProjectionTransform( + m11: 1.0, m12: 0.0, m13: 0.0, + m21: -0.7071067811865475, m22: 0.35355339059327384, m23: -0.014142135623730949, + m31: 19.09188309203678, m32: 18.282485578727798, m33: 1.3818376618407355 + ) + ), + ( + makeData2().0, + ProjectionTransform( + m11: 0.9330127018922194, m12: 0.21650635094610965, m13: 0.008660254037844387, + m21: 0.0, m22: 1.0, m23: 0.0, + m31: 2.7333395016045907, m32: -10.700317547305483, m33: 0.5719872981077807 + ) + ), + ( + makeData2().1, + ProjectionTransform( + m11: 0.06698729810778081, m12: -0.21650635094610965, m13: -0.008660254037844387, + m21: 0.0, m22: 1.0, m23: 0.0, + m31: 47.766660498395396, m32: 10.950317547305483, m33: 1.4380127018922193 + ) + ), + ] + ) + func transform(data: _Rotation3DEffect.Data, transform: ProjectionTransform) { + #expect(data.transform.isAlmostEqual(to: transform)) + } + + @Test( + arguments: [ + (makeData().0, "09182d4454fb21e93f150000803f2d00004842350000c8413d000000404500004842"), + (makeData().1, "09182d4454fb21e93f150000803f2d00004842350000c8413d0000004045000048424d0000c842"), + (makeData2().0, "0965732d3852c1f03f1d0000803f2d00004842350000c8413d0000803f450000c842"), + (makeData2().1, "0965732d3852c1f03f1d0000803f2d00004842350000c8413d0000803f450000c8424d0000c842"), + (_Rotation3DEffect.Data.init(flipWidth: .zero), ""), + (_Rotation3DEffect.Data.init(flipWidth: .nan), ""), + (_Rotation3DEffect.Data.init(flipWidth: .infinity), ""), + (_Rotation3DEffect.Data.init(flipWidth: -.infinity), ""), + (_Rotation3DEffect.Data.init(flipWidth: .ulpOfOne), "4d00008025"), + ] + ) + func pbMessage(data: _Rotation3DEffect.Data, hexString: String) throws { + func testPBDecodingIgnoreFlipWidth(hexString: String) throws { + var decodedValue = try hexString.decodePBHexString(_Rotation3DEffect.Data.self) + if decodedValue.flipWidth.isNaN { // Ignore flipWidth when decoding + decodedValue.flipWidth = data.flipWidth + } + #expect(decodedValue.isAlmostEqual(to: data)) + } + try data.testPBEncoding(hexString: hexString) + try testPBDecodingIgnoreFlipWidth(hexString: hexString) + } + } +} + +#endif diff --git a/Tests/OpenSwiftUICoreTests/Util/TimerUtilsTests.swift b/Tests/OpenSwiftUICoreTests/Util/TimerUtilsTests.swift index 0756b2b68..da258310a 100644 --- a/Tests/OpenSwiftUICoreTests/Util/TimerUtilsTests.swift +++ b/Tests/OpenSwiftUICoreTests/Util/TimerUtilsTests.swift @@ -24,23 +24,25 @@ private let isXCTestBackendEnabled = { struct TimerUtilsTests { @Test func withDelayExecutesAfterSpecifiedTime() async throws { - try await confirmation { confirmation in - var callbackExecuted = false - - let startTime = Date() - let delayInterval: TimeInterval = 2 - - let timer = withDelay(delayInterval) { - callbackExecuted = true - confirmation() + await withKnownIssue(isIntermittent: true) { + try await confirmation { confirmation in + var callbackExecuted = false + + let startTime = Date() + let delayInterval: TimeInterval = 2 + + let timer = withDelay(delayInterval) { + callbackExecuted = true + confirmation() + } + #expect(timer.isValid == true) + #expect(callbackExecuted == false) + try await Task.sleep(for: .seconds(5)) + + #expect(callbackExecuted == true) + let elapsedTime = Date().timeIntervalSince(startTime) + #expect(elapsedTime >= delayInterval) } - #expect(timer.isValid == true) - #expect(callbackExecuted == false) - try await Task.sleep(for: .seconds(5)) - - #expect(callbackExecuted == true) - let elapsedTime = Date().timeIntervalSince(startTime) - #expect(elapsedTime >= delayInterval) } } @@ -61,12 +63,14 @@ struct TimerUtilsTests { @Test func withDelayRunsOnMainRunLoop() async throws { - try await confirmation { confirmation in - let _ = withDelay(2) { - #expect(Thread.isMainThread) - confirmation() + await withKnownIssue(isIntermittent: true) { + try await confirmation { confirmation in + let _ = withDelay(2) { + #expect(Thread.isMainThread) + confirmation() + } + try await Task.sleep(for: .seconds(5)) } - try await Task.sleep(for: .seconds(5)) } } } diff --git a/Tests/OpenSwiftUISymbolDualTests/Render/CoreAnimation/CAFrameRateRangeUtilDualTests.swift b/Tests/OpenSwiftUISymbolDualTests/Render/CoreAnimation/CAFrameRateRangeUtilDualTests.swift index 469d8e532..290ee1157 100644 --- a/Tests/OpenSwiftUISymbolDualTests/Render/CoreAnimation/CAFrameRateRangeUtilDualTests.swift +++ b/Tests/OpenSwiftUISymbolDualTests/Render/CoreAnimation/CAFrameRateRangeUtilDualTests.swift @@ -1,6 +1,6 @@ // // CAFrameRateRangeUtilDualTests.swift -// OpenSwiftUICoreTests +// OpenSwiftUISymbolDualTests #if os(iOS) && canImport(SwiftUI, _underlyingVersion: 6.5.4) import QuartzCore diff --git a/Tests/OpenSwiftUISymbolDualTests/Render/GeometryEffect/Rotation3DEffectDualTests.swift b/Tests/OpenSwiftUISymbolDualTests/Render/GeometryEffect/Rotation3DEffectDualTests.swift new file mode 100644 index 000000000..509cc0410 --- /dev/null +++ b/Tests/OpenSwiftUISymbolDualTests/Render/GeometryEffect/Rotation3DEffectDualTests.swift @@ -0,0 +1,205 @@ +// +// Rotation3DEffectDualTests.swift +// OpenSwiftUISymbolDualTests + +#if canImport(SwiftUI, _underlyingVersion: 6.5.4) +import Foundation +import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +extension _Rotation3DEffect.Data { + @_silgen_name("OpenSwiftUITestStub__Rotation3DEffectDataInit") + init(swiftUI: Void) + + @_silgen_name("OpenSwiftUITestStub__Rotation3DEffectDataInitEffect") + init(swiftUI_effect: _Rotation3DEffect, size: CGSize, layoutDirection: LayoutDirection = .leftToRight) + + var swiftui_transform: ProjectionTransform { + @_silgen_name("OpenSwiftUITestStub__Rotation3DEffectDataTransform") + get + } +} + +extension _Rotation3DEffect.Data { + @_silgen_name("OpenSwiftUITestStub__Rotation3DEffectDataEncode") + func swiftUI_encode(to encoder: inout ProtobufEncoder) throws + + @_silgen_name("OpenSwiftUITestStub__Rotation3DEffectDataDecode") + init(swiftUI_from decoder: inout ProtobufDecoder) throws +} + +@Suite +struct Rotation3DEffectDualTests { + private static func makeEffect() -> _Rotation3DEffect { + _Rotation3DEffect( + angle: .radians(.pi / 4), + axis: (x: 1, y: 0, z: 0), + anchor: .center, + anchorZ: 2, + perspective: 2, + ) + } + + private static func makeEffect2() -> _Rotation3DEffect { + _Rotation3DEffect( + angle: .radians(.pi / 3), + axis: (x: 0, y: 1, z: 0), + anchor: .center, + anchorZ: 1, + perspective: 1, + ) + } + + @Suite + struct DataTests { + private static func makeData() -> (_Rotation3DEffect.Data, _Rotation3DEffect.Data) { + let effect = makeEffect() + let size = CGSize(width: 100, height: 50) + let leftData = _Rotation3DEffect.Data(swiftUI_effect: effect, size: size, layoutDirection: .leftToRight) + let rightData = _Rotation3DEffect.Data(swiftUI_effect: effect, size: size, layoutDirection: .rightToLeft) + return (leftData, rightData) + } + + private static func makeData2() -> (_Rotation3DEffect.Data, _Rotation3DEffect.Data) { + let effect = makeEffect2() + let size = CGSize(width: 100, height: 50) + let leftData = _Rotation3DEffect.Data(swiftUI_effect: effect, size: size, layoutDirection: .leftToRight) + let rightData = _Rotation3DEffect.Data(swiftUI_effect: effect, size: size, layoutDirection: .rightToLeft) + return (leftData, rightData) + } + + @Test( + arguments: [ + ( + makeData().0, + _Rotation3DEffect.Data( + angle: .radians(.pi / 4), + axis: (x: 1, y: 0, z: 0), + anchor: (x: 50, y: 25, z: 2), + perspective: 50, + flipWidth: .nan + ) + ), + ( + makeData().1, + _Rotation3DEffect.Data( + angle: .radians(.pi / 4), + axis: (x: 1, y: 0, z: 0), + anchor: (x: 50, y: 25, z: 2), + perspective: 50, + flipWidth: 100 + ) + ), + ( + makeData2().0, + _Rotation3DEffect.Data( + angle: .radians(.pi / 3), + axis: (x: 0, y: 1, z: 0), + anchor: (x: 50, y: 25, z: 1), + perspective: 100, + flipWidth: .nan + ) + ), + ( + makeData2().1, + _Rotation3DEffect.Data( + angle: .radians(.pi / 3), + axis: (x: 0, y: 1, z: 0), + anchor: (x: 50, y: 25, z: 1), + perspective: 100, + flipWidth: 100 + ) + ), + ] + ) + func dataInit(_ data: _Rotation3DEffect.Data, expected: _Rotation3DEffect.Data) { + #expect(data.isAlmostEqual(to: expected)) + } + + @Test( + arguments: [ + ( + makeData().0, + ProjectionTransform( + m11: 1.0, m12: 0.0, m13: 0.0, + m21: -0.7071067811865475, m22: 0.35355339059327384, m23: -0.014142135623730949, + m31: 19.09188309203678, m32: 18.282485578727798, m33: 1.3818376618407355 + ) + ), + ( + makeData().1, + ProjectionTransform( + m11: 1.0, m12: 0.0, m13: 0.0, + m21: -0.7071067811865475, m22: 0.35355339059327384, m23: -0.014142135623730949, + m31: 19.09188309203678, m32: 18.282485578727798, m33: 1.3818376618407355 + ) + ), + ( + makeData2().0, + ProjectionTransform( + m11: 0.9330127018922194, m12: 0.21650635094610965, m13: 0.008660254037844387, + m21: 0.0, m22: 1.0, m23: 0.0, + m31: 2.7333395016045907, m32: -10.700317547305483, m33: 0.5719872981077807 + ) + ), + ( + makeData2().1, + ProjectionTransform( + m11: 0.06698729810778081, m12: -0.21650635094610965, m13: -0.008660254037844387, + m21: 0.0, m22: 1.0, m23: 0.0, + m31: 47.766660498395396, m32: 10.950317547305483, m33: 1.4380127018922193 + ) + ), + ] + ) + func transform(data: _Rotation3DEffect.Data, transform: ProjectionTransform) { + #expect(data.swiftui_transform.isAlmostEqual(to: transform)) + } + + @Test( + arguments: [ + (makeData().0, "09182d4454fb21e93f150000803f2d00004842350000c8413d000000404500004842"), + (makeData().1, "09182d4454fb21e93f150000803f2d00004842350000c8413d0000004045000048424d0000c842"), + (makeData2().0, "0965732d3852c1f03f1d0000803f2d00004842350000c8413d0000803f450000c842"), + (makeData2().1, "0965732d3852c1f03f1d0000803f2d00004842350000c8413d0000803f450000c8424d0000c842"), + (_Rotation3DEffect.Data.init(flipWidth: .zero), ""), + (_Rotation3DEffect.Data.init(flipWidth: .nan), ""), + (_Rotation3DEffect.Data.init(flipWidth: .infinity), ""), + (_Rotation3DEffect.Data.init(flipWidth: -.infinity), ""), + (_Rotation3DEffect.Data.init(flipWidth: .ulpOfOne), "4d00008025"), + ] + ) + func pbMessage(data: _Rotation3DEffect.Data, hexString: String) throws { + try data.testPBEncoding(swiftUI_hexString: hexString) + try data.testPBDecoding(swiftUI_hexString: hexString) + } + } +} + +extension _Rotation3DEffect.Data { + func testPBEncoding(swiftUI_hexString expectedHexString: String) throws { + let data = try ProtobufEncoder.encoding { encoder in + try swiftUI_encode(to: &encoder) + } + #expect(data.hexString == expectedHexString) + } + + func testPBDecoding(swiftUI_hexString hexString: String) throws { + var decodedValue = try decodePBSwiftUIHexString(hexString) + if decodedValue.flipWidth.isNaN { // Ignore flipWidth when decoding + decodedValue.flipWidth = flipWidth + } + #expect(decodedValue.isAlmostEqual(to: self)) + } + + private func decodePBSwiftUIHexString(_ string: String) throws -> _Rotation3DEffect.Data { + guard let data = Data(hexString: string) else { + throw ProtobufDecoder.DecodingError.failed + } + var decoder = ProtobufDecoder(data) + return try _Rotation3DEffect.Data(swiftUI_from: &decoder) + } +} + +#endif