diff --git a/Sources/OpenSwiftUI/Layout/Edge/EdgeInsets.swift b/Sources/OpenSwiftUI/Layout/Edge/EdgeInsets.swift new file mode 100644 index 00000000..47229beb --- /dev/null +++ b/Sources/OpenSwiftUI/Layout/Edge/EdgeInsets.swift @@ -0,0 +1,42 @@ +// +// EdgeInsets.swift +// OpenSwiftUI +// +// Audited for iOS 18.0 +// Status: Complete + +#if canImport(Darwin) +// MARK: - EdgeInsets + Conversion + +#if canImport(UIKit) +public import UIKit +#elseif canImport(AppKit) +public import AppKit +#endif + +extension EdgeInsets { + /// Create edge insets from the equivalent NSDirectionalEdgeInsets. + @available(watchOS, unavailable) + public init(_ nsEdgeInsets: NSDirectionalEdgeInsets) { + self.init( + top: nsEdgeInsets.top, + leading: nsEdgeInsets.leading, + bottom: nsEdgeInsets.bottom, + trailing: nsEdgeInsets.trailing + ) + } +} + +extension NSDirectionalEdgeInsets { + /// Create edge insets from the equivalent EdgeInsets. + @available(watchOS, unavailable) + public init(_ edgeInsets: EdgeInsets) { + self.init( + top: edgeInsets.top, + leading: edgeInsets.leading, + bottom: edgeInsets.bottom, + trailing: edgeInsets.trailing + ) + } +} +#endif diff --git a/Sources/OpenSwiftUICore/Extension/CGAffineTransform+Extension.swift b/Sources/OpenSwiftUICore/Extension/CGAffineTransform+Extension.swift index 829211ec..e0d174f4 100644 --- a/Sources/OpenSwiftUICore/Extension/CGAffineTransform+Extension.swift +++ b/Sources/OpenSwiftUICore/Extension/CGAffineTransform+Extension.swift @@ -5,9 +5,48 @@ // Audited for iOS 18.0 // Status: Complete -#if canImport(Darwin) - +#if canImport(CoreGraphics) package import CoreGraphics +#else +package import Foundation +// FIXME: Use Silica or other implementation +public struct CGAffineTransform: Equatable { + public init() { + a = .zero + b = .zero + c = .zero + d = .zero + tx = .zero + ty = .zero + } + + public init(a: Double, b: Double, c: Double, d: Double, tx: Double, ty: Double) { + self.a = a + self.b = b + self.c = c + self.d = d + 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") + } + + public func inverted() -> CGAffineTransform { + preconditionFailure("Unimplemented") + } +} +#endif extension CGAffineTransform { package init(rotation: Angle) { @@ -49,29 +88,32 @@ extension CGAffineTransform { extension CGAffineTransform: ProtobufMessage { package func encode(to encoder: inout ProtobufEncoder) throws { - encoder.cgFloatField(1, a, defaultValue: 1) - encoder.cgFloatField(2, b, defaultValue: 0) - encoder.cgFloatField(3, c, defaultValue: 0) - encoder.cgFloatField(4, d, defaultValue: 1) - encoder.cgFloatField(5, tx, defaultValue: 0) - encoder.cgFloatField(6, ty, defaultValue: 0) + withUnsafePointer(to: self) { pointer in + let pointer = UnsafeRawPointer(pointer).assumingMemoryBound(to: CGFloat.self) + let bufferPointer = UnsafeBufferPointer(start: pointer, count: 6) + for index: UInt in 1 ... 6 { + encoder.cgFloatField( + index, + bufferPointer[Int(index &- 1)], + defaultValue: (index == 1 || index == 4) ? 1 : 0 + ) + } + } } package init(from decoder: inout ProtobufDecoder) throws { var transform = CGAffineTransform.identity - while let field = try decoder.nextField() { - switch field.tag { - case 1: transform.a = try decoder.cgFloatField(field) - case 2: transform.b = try decoder.cgFloatField(field) - case 3: transform.c = try decoder.cgFloatField(field) - case 4: transform.d = try decoder.cgFloatField(field) - case 5: transform.tx = try decoder.cgFloatField(field) - case 6: transform.ty = try decoder.cgFloatField(field) - default: try decoder.skipField(field) + try withUnsafeMutablePointer(to: &transform) { pointer in + let pointer = UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: CGFloat.self) + let bufferPointer = UnsafeMutableBufferPointer(start: pointer, count: 6) + while let field = try decoder.nextField() { + let tag = field.tag + switch tag { + case 1...6: bufferPointer[Int(tag &- 1)] = try decoder.cgFloatField(field) + default: try decoder.skipField(field) + } } } self = transform } } - -#endif diff --git a/Sources/OpenSwiftUICore/Extension/CGRect+Extension.swift b/Sources/OpenSwiftUICore/Extension/CGRect+Extension.swift index 70d61a79..31803c41 100644 --- a/Sources/OpenSwiftUICore/Extension/CGRect+Extension.swift +++ b/Sources/OpenSwiftUICore/Extension/CGRect+Extension.swift @@ -245,21 +245,29 @@ extension CGRect: Animatable { extension CGRect: ProtobufMessage { package func encode(to encoder: inout ProtobufEncoder) { - encoder.cgFloatField(1, x) - encoder.cgFloatField(2, y) - encoder.cgFloatField(3, width) - encoder.cgFloatField(4, height) + withUnsafePointer(to: self) { pointer in + let pointer = UnsafeRawPointer(pointer).assumingMemoryBound(to: CGFloat.self) + let bufferPointer = UnsafeBufferPointer(start: pointer, count: 4) + for index: UInt in 1 ... 4 { + encoder.cgFloatField( + index, + bufferPointer[Int(index &- 1)] + ) + } + } } package init(from decoder: inout ProtobufDecoder) throws { var rect = CGRect.zero - while let field = try decoder.nextField() { - switch field.tag { - case 1: rect.x = try decoder.cgFloatField(field) - case 2: rect.y = try decoder.cgFloatField(field) - case 3: rect.size.width = try decoder.cgFloatField(field) - case 4: rect.size.height = try decoder.cgFloatField(field) - default: try decoder.skipField(field) + try withUnsafeMutablePointer(to: &rect) { pointer in + let pointer = UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: CGFloat.self) + let bufferPointer = UnsafeMutableBufferPointer(start: pointer, count: 4) + while let field = try decoder.nextField() { + let tag = field.tag + switch tag { + case 1...6: bufferPointer[Int(tag &- 1)] = try decoder.cgFloatField(field) + default: try decoder.skipField(field) + } } } self = rect diff --git a/Sources/OpenSwiftUICore/Graphic/Color/ColorMatrix.swift b/Sources/OpenSwiftUICore/Graphic/Color/ColorMatrix.swift index 27465937..dbfd04a0 100644 --- a/Sources/OpenSwiftUICore/Graphic/Color/ColorMatrix.swift +++ b/Sources/OpenSwiftUICore/Graphic/Color/ColorMatrix.swift @@ -295,56 +295,34 @@ extension _ColorMatrix: ShapeStyle { extension _ColorMatrix: ProtobufMessage { package func encode(to encoder: inout ProtobufEncoder) { - encoder.floatField(1, m11, defaultValue: 1.0) - encoder.floatField(2, m12, defaultValue: 0.0) - encoder.floatField(3, m13, defaultValue: 0.0) - encoder.floatField(4, m14, defaultValue: 0.0) - encoder.floatField(5, m15, defaultValue: 0.0) - encoder.floatField(6, m21, defaultValue: 0.0) - encoder.floatField(7, m22, defaultValue: 1.0) - encoder.floatField(8, m23, defaultValue: 0.0) - encoder.floatField(9, m24, defaultValue: 0.0) - encoder.floatField(10, m25, defaultValue: 0.0) - encoder.floatField(11, m31, defaultValue: 0.0) - encoder.floatField(12, m32, defaultValue: 0.0) - encoder.floatField(13, m33, defaultValue: 1.0) - encoder.floatField(14, m34, defaultValue: 0.0) - encoder.floatField(15, m35, defaultValue: 0.0) - encoder.floatField(16, m41, defaultValue: 0.0) - encoder.floatField(17, m42, defaultValue: 0.0) - encoder.floatField(18, m43, defaultValue: 0.0) - encoder.floatField(19, m44, defaultValue: 1.0) - encoder.floatField(20, m45, defaultValue: 0.0) + withUnsafePointer(to: self) { pointer in + let pointer = UnsafeRawPointer(pointer).assumingMemoryBound(to: Float.self) + let bufferPointer = UnsafeBufferPointer(start: pointer, count: 20) + for index: UInt in 1 ... 6 { + encoder.floatField( + index, + bufferPointer[Int(index &- 1)], + defaultValue: (index == 1 || index == 7 || index == 13 || index == 19) ? 1 : 0 + ) + } + } } package init(from decoder: inout ProtobufDecoder) throws { - self = _ColorMatrix() - while let field = try decoder.nextField() { - switch field.tag { - case 1: m11 = try decoder.floatField(field) - case 2: m12 = try decoder.floatField(field) - case 3: m13 = try decoder.floatField(field) - case 4: m14 = try decoder.floatField(field) - case 5: m15 = try decoder.floatField(field) - case 6: m21 = try decoder.floatField(field) - case 7: m22 = try decoder.floatField(field) - case 8: m23 = try decoder.floatField(field) - case 9: m24 = try decoder.floatField(field) - case 10: m25 = try decoder.floatField(field) - case 11: m31 = try decoder.floatField(field) - case 12: m32 = try decoder.floatField(field) - case 13: m33 = try decoder.floatField(field) - case 14: m34 = try decoder.floatField(field) - case 15: m35 = try decoder.floatField(field) - case 16: m41 = try decoder.floatField(field) - case 17: m42 = try decoder.floatField(field) - case 18: m43 = try decoder.floatField(field) - case 19: m44 = try decoder.floatField(field) - case 20: m45 = try decoder.floatField(field) - default: - try decoder.skipField(field) + var matrix = _ColorMatrix() + try withUnsafeMutablePointer(to: &matrix) { pointer in + let pointer = UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: Float.self) + let bufferPointer = UnsafeMutableBufferPointer(start: pointer, count: 20) + while let field = try decoder.nextField() { + let tag = field.tag + switch tag { + case 1...6: bufferPointer[Int(tag &- 1)] = try decoder.floatField(field) + default: try decoder.skipField(field) + } } } + self = matrix + } } diff --git a/Sources/OpenSwiftUICore/Layout/Edge/EdgeInsets.swift b/Sources/OpenSwiftUICore/Layout/Edge/EdgeInsets.swift index f6ed7195..408e5461 100644 --- a/Sources/OpenSwiftUICore/Layout/Edge/EdgeInsets.swift +++ b/Sources/OpenSwiftUICore/Layout/Edge/EdgeInsets.swift @@ -1,12 +1,14 @@ // // EdgeInsets.swift -// OpenSwiftUI +// OpenSwiftUICore // -// Audited for iOS 15.5 +// Audited for iOS 18.0 // Status: Complete public import Foundation +// MARK: - EdgeInsets + /// The inset distances for the sides of a rectangle. @frozen public struct EdgeInsets: Equatable { @@ -29,9 +31,125 @@ public struct EdgeInsets: Equatable { } package static var zero: EdgeInsets { EdgeInsets() } +} + +// MARK: - OptionalEdgeInsets + +package struct OptionalEdgeInsets: Hashable { + package static var none: OptionalEdgeInsets { + OptionalEdgeInsets() + } - @inline(__always) - init(_ value: CGFloat, edges: Edge.Set) { + package static var zero: OptionalEdgeInsets { + OptionalEdgeInsets(0, edges: .all) + } + + package var top: CGFloat? + package var leading: CGFloat? + package var bottom: CGFloat? + package var trailing: CGFloat? + + package init() {} + + package init( + top: CGFloat? = nil, + leading: CGFloat? = nil, + bottom: CGFloat? = nil, + trailing: CGFloat? = nil + ) { + self.top = top + self.leading = leading + self.bottom = bottom + self.trailing = trailing + } + + package init(_ value: CGFloat?, edges: Edge.Set) { + if edges.contains(.top) { top = value } + if edges.contains(.leading) { leading = value } + if edges.contains(.bottom) { bottom = value } + if edges.contains(.trailing) { trailing = value } + } + + package init(_ value: EdgeInsets, edges: Edge.Set) { + if edges.contains(.top) { top = value.top } + if edges.contains(.leading) { leading = value.leading } + if edges.contains(.bottom) { bottom = value.bottom } + if edges.contains(.trailing) { trailing = value.trailing } + } + + package subscript(edge: Edge) -> CGFloat? { + get { + switch edge { + case .top: return top + case .leading: return leading + case .bottom: return bottom + case .trailing: return trailing + } + } + set { + switch edge { + case .top: top = newValue + case .leading: leading = newValue + case .bottom: bottom = newValue + case .trailing: trailing = newValue + } + } + } + + package func adding(_ other: OptionalEdgeInsets) -> OptionalEdgeInsets { + var result = self + if let otherTop = other.top { result.top = (result.top ?? 0) + otherTop } + if let otherLeading = other.leading { result.leading = (result.leading ?? 0) + otherLeading } + if let otherBottom = other.bottom { result.bottom = (result.bottom ?? 0) + otherBottom } + if let otherTrailing = other.trailing { result.trailing = (result.trailing ?? 0) + otherTrailing } + return result + } + + package func `in`(axes: Axis.Set) -> OptionalEdgeInsets { + var result = OptionalEdgeInsets() + if axes.contains(.horizontal) { + result.leading = leading + result.trailing = trailing + } + if axes.contains(.vertical) { + result.top = top + result.bottom = bottom + } + return result + } + + package func `in`(edges: Edge.Set) -> OptionalEdgeInsets { + var result = OptionalEdgeInsets() + if edges.contains(.top) { result.top = top } + if edges.contains(.leading) { result.leading = leading } + if edges.contains(.bottom) { result.bottom = bottom } + if edges.contains(.trailing) { result.trailing = trailing } + return result + } + + package func `in`(axes: Axis.Set) -> EdgeInsets { + EdgeInsets( + top: axes.contains(.vertical) ? (top ?? 0) : 0, + leading: axes.contains(.horizontal) ? (leading ?? 0) : 0, + bottom: axes.contains(.vertical) ? (bottom ?? 0) : 0, + trailing: axes.contains(.horizontal) ? (trailing ?? 0) : 0 + ) + } + + package func `in`(edges: Edge.Set) -> EdgeInsets { + EdgeInsets( + top: edges.contains(.top) ? (top ?? 0) : 0, + leading: edges.contains(.leading) ? (leading ?? 0) : 0, + bottom: edges.contains(.bottom) ? (bottom ?? 0) : 0, + trailing: edges.contains(.trailing) ? (trailing ?? 0) : 0 + ) + } +} + +// MARK: - EdgeInsets + Extension + +extension EdgeInsets { + package init(_ value: CGFloat, edges: Edge.Set) { self.init( top: edges.contains(.top) ? value : 0, leading: edges.contains(.leading) ? value : 0, @@ -40,7 +158,56 @@ public struct EdgeInsets: Equatable { ) } - func `in`(edges: Edge.Set) -> EdgeInsets { + package init(horizontal: CGFloat, vertical: CGFloat) { + self.init( + top: vertical, + leading: horizontal, + bottom: vertical, + trailing: horizontal + ) + } + + package func subtracting(_ other: EdgeInsets) -> EdgeInsets { + EdgeInsets( + top: top - other.top, + leading: leading - other.leading, + bottom: bottom - other.bottom, + trailing: trailing - other.trailing + ) + } + + package var isEmpty: Bool { + top == 0 && leading == 0 && bottom == 0 && trailing == 0 + } + + package var vertical: CGFloat { + top + bottom + } + + package var horizontal: CGFloat { + leading + trailing + } + + package subscript(edge: Edge) -> CGFloat { + get { + switch edge { + case .top: return top + case .leading: return leading + case .bottom: return bottom + case .trailing: return trailing + } + } + set { + switch edge { + case .top: top = newValue + case .leading: leading = newValue + case .bottom: bottom = newValue + case .trailing: trailing = newValue + } + } + } + + package func `in`(_ edges: Edge.Set) -> EdgeInsets { EdgeInsets( top: edges.contains(.top) ? top : 0, leading: edges.contains(.leading) ? leading : 0, @@ -48,93 +215,169 @@ public struct EdgeInsets: Equatable { trailing: edges.contains(.trailing) ? trailing : 0 ) } - - mutating func formPointwiseMin(insets: EdgeInsets) { - if insets.top < top { top = insets.top } - if insets.leading < leading { leading = insets.leading } - if insets.bottom < bottom { bottom = insets.bottom } - if insets.trailing < trailing { trailing = insets.trailing } + + package func scaled(by scalar: CGFloat) -> EdgeInsets { + EdgeInsets( + top: top * scalar, + leading: leading * scalar, + bottom: bottom * scalar, + trailing: trailing * scalar + ) } -} -extension EdgeInsets { - @usableFromInline - @inline(__always) - init(_all all: CGFloat) { - self.init(top: all, leading: all, bottom: all, trailing: all) + package func adding(_ other: EdgeInsets) -> EdgeInsets { + EdgeInsets( + top: top + other.top, + leading: leading + other.leading, + bottom: bottom + other.bottom, + trailing: trailing + other.trailing + ) + } + + package func adding(_ other: OptionalEdgeInsets) -> EdgeInsets { + var result = self + if let otherTop = other.top { result.top += otherTop } + if let otherLeading = other.leading { result.leading += otherLeading } + if let otherBottom = other.bottom { result.bottom += otherBottom } + if let otherTrailing = other.trailing { result.trailing += otherTrailing } + return result } -} -extension CGSize { - @inline(__always) - func inset(by insets: EdgeInsets) -> CGSize { - CGSize( - width: max(width - insets.leading - insets.trailing, 0), - height: max(height - insets.top - insets.bottom, 0) + package func merge(_ other: OptionalEdgeInsets) -> EdgeInsets { + EdgeInsets( + top: other.top ?? top, + leading: other.leading ?? leading, + bottom: other.bottom ?? bottom, + trailing: other.trailing ?? trailing ) } - - func outset(by insets: EdgeInsets) -> CGSize { - CGSize( - width: max(width + insets.leading + insets.trailing, 0), - height: max(height + insets.top + insets.bottom, 0) + + package var negatedInsets: EdgeInsets { + EdgeInsets( + top: -top, + leading: -leading, + bottom: -bottom, + trailing: -trailing ) } -} -// MARK: - Sendable + package var originOffset: CGSize { + CGSize(width: leading, height: top) + } -extension EdgeInsets: Sendable {} + package mutating func formPointwiseMin(_ other: EdgeInsets) { + top = min(top, other.top) + leading = min(leading, other.leading) + bottom = min(bottom, other.bottom) + trailing = min(trailing, other.trailing) + } -#if canImport(Darwin) + package mutating func formPointwiseMax(_ other: EdgeInsets) { + top = max(top, other.top) + leading = max(leading, other.leading) + bottom = max(bottom, other.bottom) + trailing = max(trailing, other.trailing) + } -// MARK: - UIKit/AppKit integration + @inline(__always) + package mutating func xFlipIfRightToLeft(layoutDirection: () -> LayoutDirection) { + let leading = self.leading + let trailing = self.trailing + guard leading != trailing else { return } + guard layoutDirection() == .rightToLeft else { return } + self.leading = trailing + self.trailing = leading + } +} -#if canImport(UIKit) -public import UIKit -#elseif canImport(AppKit) -public import AppKit -#endif extension EdgeInsets { - /// Create edge insets from the equivalent NSDirectionalEdgeInsets. - @inline(__always) - @available(watchOS, unavailable) - public init(_ nsEdgeInsets: NSDirectionalEdgeInsets) { - self.init( - top: nsEdgeInsets.top, - leading: nsEdgeInsets.leading, - bottom: nsEdgeInsets.bottom, - trailing: nsEdgeInsets.trailing + package func hash(into hasher: inout Hasher) { + hasher.combine(top) + hasher.combine(leading) + hasher.combine(bottom) + hasher.combine(trailing) + } +} + +extension CGRect { + package func inset(by insets: EdgeInsets, layoutDirection: @autoclosure () -> LayoutDirection) -> CGRect { + guard !isNull else { return self } + var s = standardized + s.x += layoutDirection() == .rightToLeft ? insets.trailing : insets.leading + s.y += insets.top + s.size.width -= insets.horizontal + s.size.height -= insets.vertical + + guard s.size.width >= 0, + s.size.height >= 0 + else { return .null } + return s + } + + package func inset(by insets: EdgeInsets) -> CGRect { + inset(by: insets, layoutDirection: .leftToRight) + } + + package func outset(by insets: EdgeInsets, layoutDirection: @autoclosure () -> LayoutDirection = .leftToRight) -> CGRect { + guard !isNull else { return self } + var s = standardized + s.x -= layoutDirection() == .rightToLeft ? insets.trailing : insets.leading + s.y -= insets.top + s.size.width += insets.horizontal + s.size.height += insets.vertical + + guard s.size.width >= 0, + s.size.height >= 0 + else { return .null } + return s + } + + package func outset(by insets: EdgeInsets) -> CGRect { + outset(by: insets, layoutDirection: .leftToRight) + } +} + +extension CGSize { + package func inset(by insets: EdgeInsets) -> CGSize { + CGSize( + width: max(width - insets.horizontal, 0), + height: max(height - insets.vertical, 0) + ) + } + + package func outset(by insets: EdgeInsets) -> CGSize { + CGSize( + width: max(width + insets.horizontal, 0), + height: max(height + insets.vertical, 0) ) } } -extension NSDirectionalEdgeInsets { - @inline(__always) - @available(watchOS, unavailable) - public init(_ edgeInsets: EdgeInsets) { - self.init( - top: edgeInsets.top, - leading: edgeInsets.leading, - bottom: edgeInsets.bottom, - trailing: edgeInsets.trailing +extension CGPoint { + package func offset(by insets: EdgeInsets) -> CGPoint { + CGPoint( + x: insets.leading + x, + y: insets.top + y ) } } -#endif // MARK: - EdgeInsets + Animatable extension EdgeInsets: Animatable, _VectorMath { - @inlinable - public var animatableData: AnimatablePair>> { + public typealias AnimatableData = AnimatablePair>> + + public var animatableData: AnimatableData { + @inlinable get { .init(top, .init(leading, .init(bottom, trailing))) } + @inlinable set { - let top = newValue[].0 - let leading = newValue[].1[].0 - let (bottom, trailing) = newValue[].1[].1[] + let top = newValue.first + let leading = newValue.second.first + let bottom = newValue.second.second.first + let trailing = newValue.second.second.second self = .init( top: top, leading: leading, bottom: bottom, trailing: trailing ) @@ -142,34 +385,9 @@ extension EdgeInsets: Animatable, _VectorMath { } } -// MARK: - CodableEdgeInsets - -package struct CodableEdgeInsets: CodableProxy { - package var base: EdgeInsets - - @inline(__always) - init(base: EdgeInsets) { self.base = base } - - package init(from decoder: Decoder) throws { - var container = try decoder.unkeyedContainer() - let top = try container.decode(CGFloat.self) - let leading = try container.decode(CGFloat.self) - let bottom = try container.decode(CGFloat.self) - let trailing = try container.decode(CGFloat.self) - base = EdgeInsets(top: top, leading: leading, bottom: bottom, trailing: trailing) - } - - package func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - try container.encode(base.top) - try container.encode(base.leading) - try container.encode(base.bottom) - try container.encode(base.trailing) +extension EdgeInsets { + @usableFromInline + package init(_all all: CGFloat) { + self.init(top: all, leading: all, bottom: all, trailing: all) } } - -// MARK: - EdgeInsets + CodableByProxy - -extension EdgeInsets: CodableByProxy { - package var codingProxy: CodableEdgeInsets { CodableEdgeInsets(base: self) } -} diff --git a/Sources/OpenSwiftUICore/Layout/Transform/Placement.swift b/Sources/OpenSwiftUICore/Layout/Geometry/Placement.swift similarity index 100% rename from Sources/OpenSwiftUICore/Layout/Transform/Placement.swift rename to Sources/OpenSwiftUICore/Layout/Geometry/Placement.swift diff --git a/Sources/OpenSwiftUICore/Layout/Geometry/ProjectionTransform.swift b/Sources/OpenSwiftUICore/Layout/Geometry/ProjectionTransform.swift new file mode 100644 index 00000000..4f844d29 --- /dev/null +++ b/Sources/OpenSwiftUICore/Layout/Geometry/ProjectionTransform.swift @@ -0,0 +1,237 @@ +// +// ProjectionTransform.swift +// OpenSwiftUICore +// +// Audited for iOS 18 +// Status: Complete + +public import Foundation +#if canImport(QuartzCore) +public import QuartzCore +#endif + +@frozen +public struct ProjectionTransform { + public var m11: CGFloat = 1.0, m12: CGFloat = 0.0, m13: CGFloat = 0.0 + public var m21: CGFloat = 0.0, m22: CGFloat = 1.0, m23: CGFloat = 0.0 + public var m31: CGFloat = 0.0, m32: CGFloat = 0.0, m33: CGFloat = 1.0 + + @inline(__always) + package init ( + m11: CGFloat, m12: CGFloat, m13: CGFloat, + m21: CGFloat, m22: CGFloat, m23: CGFloat, + m31: CGFloat, m32: CGFloat, m33: CGFloat + ) { + self.m11 = m11 + self.m12 = m12 + self.m13 = m13 + self.m21 = m21 + self.m22 = m22 + self.m23 = m23 + self.m31 = m31 + self.m32 = m32 + self.m33 = m33 + } + + @inlinable + public init() {} + + #if canImport(QuartzCore) + @inlinable + public init(_ m: CGAffineTransform) { + m11 = m.a + m12 = m.b + m21 = m.c + m22 = m.d + m31 = m.tx + m32 = m.ty + } + + @inlinable + public init(_ m: CATransform3D) { + m11 = m.m11 + m12 = m.m12 + m13 = m.m14 + m21 = m.m21 + m22 = m.m22 + m23 = m.m24 + m31 = m.m41 + m32 = m.m42 + m33 = m.m44 + } + #endif + + @inlinable + public var isIdentity: Bool { + self == ProjectionTransform() + } + + @inlinable + public var isAffine: Bool { + m13 == 0 && m23 == 0 && m33 == 1 + } + + package var determinant: CGFloat { + if isAffine { + return m11 * m22 - m12 * m21 + } + let det1 = m22 * m33 - m23 * m32 + let det2 = m21 * m33 - m23 * m31 + let det3 = m21 * m32 - m22 * m31 + + return m11 * det1 - m12 * det2 + m13 * det3 + } + + package var isInvertible: Bool { + determinant != 0 + } + + public mutating func invert() -> Bool { + let det = determinant + guard det != 0 else { return false } + + let invDet = 1.0 / det + + // Calculate cofactors + let c11 = m22 * m33 - m23 * m32 + let c12 = m21 * m33 - m23 * m31 + let c13 = m21 * m32 - m22 * m31 + + let c21 = m12 * m33 - m13 * m32 + let c22 = m11 * m33 - m13 * m31 + let c23 = m11 * m32 - m12 * m31 + + let c31 = m12 * m23 - m13 * m22 + let c32 = m11 * m23 - m13 * m21 + let c33 = m11 * m22 - m12 * m21 + + // Calculate adjugate matrix and multiply by 1/determinant + m11 = c11 * invDet + m12 = -c21 * invDet + m13 = c31 * invDet + m21 = -c12 * invDet + m22 = c22 * invDet + m23 = -c32 * invDet + m31 = c13 * invDet + m32 = -c23 * invDet + m33 = c33 * invDet + + return true + } + + public func inverted() -> ProjectionTransform { + var copy = self + let result = copy.invert() + if !result { + Log.runtimeIssues("Cannot invert singular matrix") + } + return copy + } +} + +extension ProjectionTransform: Equatable {} + +extension ProjectionTransform { + @inline(__always) + @inlinable + func dot(_ a: (CGFloat, CGFloat, CGFloat), _ b: (CGFloat, CGFloat, CGFloat)) -> CGFloat { + a.0 * b.0 + a.1 * b.1 + a.2 * b.2 + } + + @inlinable + public func concatenating(_ rhs: ProjectionTransform) -> ProjectionTransform { + var m = ProjectionTransform() + m.m11 = dot((m11, m12, m13), (rhs.m11, rhs.m21, rhs.m31)) + m.m12 = dot((m11, m12, m13), (rhs.m12, rhs.m22, rhs.m32)) + m.m13 = dot((m11, m12, m13), (rhs.m13, rhs.m23, rhs.m33)) + m.m21 = dot((m21, m22, m23), (rhs.m11, rhs.m21, rhs.m31)) + m.m22 = dot((m21, m22, m23), (rhs.m12, rhs.m22, rhs.m32)) + m.m23 = dot((m21, m22, m23), (rhs.m13, rhs.m23, rhs.m33)) + m.m31 = dot((m31, m32, m33), (rhs.m11, rhs.m21, rhs.m31)) + m.m32 = dot((m31, m32, m33), (rhs.m12, rhs.m22, rhs.m32)) + m.m33 = dot((m31, m32, m33), (rhs.m13, rhs.m23, rhs.m33)) + return m + } +} + +extension CGPoint { + public func applying(_ t: ProjectionTransform) -> CGPoint { + let w = t.m13 * x + t.m23 * y + t.m33 + + let scale: CGFloat + if w == 1 { + scale = 1 + } else if w <= 0 { + scale = .infinity + } else { + scale = 1 / w + } + + let px = (t.m11 * x + t.m21 * y + t.m31) * scale + let py = (t.m12 * x + t.m22 * y + t.m32) * scale + + return CGPoint(x: px, y: py) + } +} +extension CGPoint { + package func unapplying(_ m: ProjectionTransform) -> CGPoint { + var inverse = m + guard inverse.invert() else { return self } + return applying(inverse) + } +} + +extension CGAffineTransform { + package init(_ m: ProjectionTransform) { + self.init( + a: m.m11, b: m.m12, + c: m.m21, d: m.m22, + tx: m.m31, ty: m.m32 + ) + } +} + +#if canImport(QuartzCore) +extension CATransform3D { + package init(_ m: ProjectionTransform) { + self.init( + m11: m.m11, m12: m.m12, m13: 0, m14: m.m13, + m21: m.m21, m22: m.m22, m23: 0, m24: m.m23, + m31: 0, m32: 0, m33: 1, m34: 0, + m41: m.m31, m42: m.m32, m43: 0, m44: m.m33 + ) + } +} +#endif + +extension ProjectionTransform: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) { + withUnsafePointer(to: self) { pointer in + let pointer = UnsafeRawPointer(pointer).assumingMemoryBound(to: CGFloat.self) + let bufferPointer = UnsafeBufferPointer(start: pointer, count: 9) + for index: UInt in 1 ... 9 { + encoder.cgFloatField( + index, + bufferPointer[Int(index &- 1)], + defaultValue: (index == 1 || index == 5 || index == 9) ? 1 : 0 + ) + } + } + } + + package init(from decoder: inout ProtobufDecoder) throws { + var transform = ProjectionTransform() + try withUnsafeMutablePointer(to: &transform) { pointer in + let pointer = UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: CGFloat.self) + let bufferPointer = UnsafeMutableBufferPointer(start: pointer, count: 9) + while let field = try decoder.nextField() { + let tag = field.tag + switch tag { + case 1...9: bufferPointer[Int(tag &- 1)] = try decoder.cgFloatField(field) + default: try decoder.skipField(field) + } + } + } + self = transform + } +} diff --git a/Sources/OpenSwiftUICore/Layout/Transform/ProposedSize.swift b/Sources/OpenSwiftUICore/Layout/Geometry/ProposedSize.swift similarity index 100% rename from Sources/OpenSwiftUICore/Layout/Transform/ProposedSize.swift rename to Sources/OpenSwiftUICore/Layout/Geometry/ProposedSize.swift diff --git a/Sources/OpenSwiftUICore/Layout/Geometry/ScrollGeometry.swift b/Sources/OpenSwiftUICore/Layout/Geometry/ScrollGeometry.swift new file mode 100644 index 00000000..5bc6fbaf --- /dev/null +++ b/Sources/OpenSwiftUICore/Layout/Geometry/ScrollGeometry.swift @@ -0,0 +1,164 @@ +// +// ScrollGeometry.swift +// OpenSwiftUICore +// +// Audited for iOS 18.0 +// Status: Complete + +public import Foundation + +/// A type that defines the geometry of a scroll view. +/// +/// OpenSwiftUI provides you values of this type when using modifiers like +/// ``View/onScrollGeometryChange(_:action:)`` or +/// ``View/onScrollPhaseChange(_:)``. +public struct ScrollGeometry: Equatable, Sendable { + /// The content offset of the scroll view. + /// + /// This is the position of the scroll view within its overall + /// content size. This value may extend before zero or beyond + /// the content size when the content insets of the scroll view + /// are non-zero or when rubber banding. + public var contentOffset: CGPoint { + didSet { + visibleRect.origin += (contentOffset - oldValue) + } + } + + /// The size of the content of the scroll view. + /// + /// Unlike the container size of the scroll view, this refers to the + /// total size of the content of the scroll view which can be smaller + /// or larger than its containing size. + public var contentSize: CGSize + + /// The content insets of the scroll view. + /// + /// Adding these insets to the content size of the scroll view + /// will give you the total scrollable space of the scroll view. + public var contentInsets: EdgeInsets + + /// The size of the container of the scroll view. + /// + /// This is the overall size of the scroll view. Combining this + /// and the content offset will give you the current visible rect + /// of the scroll view. + public var containerSize: CGSize { + didSet { + visibleRect.size += (containerSize - oldValue) + } + } + + /// The visible rect of the scroll view. + /// + /// This value is computed from the scroll view's content offset, content + /// insets, and its container size. + public private(set) var visibleRect: CGRect + + /// The bounds rect of the scroll view. + /// + /// Unlike the visible rect, this value is within the content insets + /// of the scroll view. + public var bounds: CGRect { + CGRect(origin: contentOffset, size: containerSize) + } + + package init(contentOffset: CGPoint, contentSize: CGSize, contentInsets: EdgeInsets, containerSize: CGSize, visibleRect: CGRect) { + self.contentOffset = contentOffset + self.contentSize = contentSize + self.contentInsets = contentInsets + self.containerSize = containerSize + self.visibleRect = visibleRect + } +} +extension ScrollGeometry { + public init(contentOffset: CGPoint, contentSize: CGSize, contentInsets: EdgeInsets, containerSize: CGSize) { + self.init( + contentOffset: contentOffset, + contentSize: contentSize, + contentInsets: contentInsets, + containerSize: containerSize, + visibleRect: CGRect(origin: contentOffset, size: containerSize) + ) + } + + package static var zero: ScrollGeometry { + ScrollGeometry(contentOffset: .zero, contentSize: .zero, contentInsets: .zero, containerSize: .zero) + } + + package static func viewTransform(contentInsets: EdgeInsets, contentSize: CGSize, containerSize: CGSize) -> ScrollGeometry { + let rect = CGRect(origin: .zero, size: containerSize).outset(by: contentInsets) + return ScrollGeometry( + contentOffset: .zero, + contentSize: contentSize, + contentInsets: contentInsets, + containerSize: containerSize, + visibleRect: CGRect(origin: rect.origin, size: rect.size.flushingNegatives) + ) + } + + package static func rootViewTransform(contentOffset: CGPoint, containerSize: CGSize) -> ScrollGeometry { + ScrollGeometry( + contentOffset: contentOffset, + contentSize: CGSize(width: CGFloat.infinity, height: CGFloat.infinity), + contentInsets: .zero, + containerSize: containerSize + ) + } + + package static func size(_ size: CGSize) -> ScrollGeometry { + ScrollGeometry(contentOffset: .zero, contentSize: size, contentInsets: .zero, containerSize: size) + } +} + +extension ScrollGeometry { + package mutating func applyLayoutDirection(_ direction: LayoutDirection, contentSize: CGSize?) { + guard direction == .rightToLeft else { return } + contentOffset.x = (contentSize?.width ?? self.contentSize.width) - bounds.maxX + } + + package mutating func translate(by translation: CGSize, limit: CGSize) { + let newOffsetX = min( + max(contentInsets.trailing + limit.width - containerSize.width, 0), + max(contentOffset.x + translation.width, -contentInsets.leading) + ) + let newOffsetY = min( + max(contentInsets.bottom + limit.height - containerSize.height, 0), + max(contentOffset.y + translation.height, -contentInsets.top) + ) + contentOffset = CGPoint(x: newOffsetX, y: newOffsetY) + } + + package mutating func outsetForAX(limit: CGSize) { + if containerSize.width < limit.width { + let offsetX = max(contentOffset.x - containerSize.width, 0) + let newOffsetX = min(offsetX, contentOffset.x) + let originalOffsetX = contentOffset.x + contentOffset.x = newOffsetX + + let adjustedWidth = containerSize.width + originalOffsetX - newOffsetX + let remainingWidth = limit.width - originalOffsetX + let extendedWidth = containerSize.width + adjustedWidth + + containerSize.width = max(min(remainingWidth, extendedWidth), adjustedWidth) + } + if containerSize.height < limit.height { + let offsetY = max(contentOffset.y - containerSize.height, 0) + let newOffsetY = min(offsetY, contentOffset.y) + let originalOffsetY = contentOffset.y + contentOffset.y = newOffsetY + + let adjustedHeight = containerSize.height + originalOffsetY - newOffsetY + let remainingHeight = limit.height - originalOffsetY + let extendedHeight = containerSize.height + adjustedHeight + + containerSize.height = max(min(remainingHeight, extendedHeight), adjustedHeight) + } + } +} + +extension ScrollGeometry: CustomDebugStringConvertible { + public var debugDescription: String { + "" + } +} diff --git a/Sources/OpenSwiftUICore/Layout/Transform/Spacing.swift b/Sources/OpenSwiftUICore/Layout/Geometry/Spacing.swift similarity index 100% rename from Sources/OpenSwiftUICore/Layout/Transform/Spacing.swift rename to Sources/OpenSwiftUICore/Layout/Geometry/Spacing.swift diff --git a/Sources/OpenSwiftUICore/Layout/Transform/ProjectionTransform.swift b/Sources/OpenSwiftUICore/Layout/Transform/ProjectionTransform.swift deleted file mode 100644 index cc873ccb..00000000 --- a/Sources/OpenSwiftUICore/Layout/Transform/ProjectionTransform.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// ProjectionTransform.swift -// OpenSwiftUI -// -// Audited for iOS 15.5 -// Status: WIP - -public import Foundation -#if canImport(QuartzCore) -public import QuartzCore -#endif - -@frozen -public struct ProjectionTransform { - public var m11: CGFloat = 1.0, m12: CGFloat = 0.0, m13: CGFloat = 0.0 - public var m21: CGFloat = 0.0, m22: CGFloat = 1.0, m23: CGFloat = 0.0 - public var m31: CGFloat = 0.0, m32: CGFloat = 0.0, m33: CGFloat = 1.0 - - @inlinable - public init() {} - - #if canImport(QuartzCore) - @inlinable - public init(_ m: CGAffineTransform) { - m11 = m.a - m12 = m.b - m21 = m.c - m22 = m.d - m31 = m.tx - m32 = m.ty - } - - @inlinable public init(_ m: CATransform3D) { - m11 = m.m11 - m12 = m.m12 - m13 = m.m14 - m21 = m.m21 - m22 = m.m22 - m23 = m.m24 - m31 = m.m41 - m32 = m.m42 - m33 = m.m44 - } - #endif - - @inlinable - public var isIdentity: Bool { - self == ProjectionTransform() - } - - @inlinable - public var isAffine: Bool { - m13 == 0 && m23 == 0 && m33 == 1 - } - - public mutating func invert() -> Bool { - // TODO: - false - } - - public func inverted() -> ProjectionTransform { - // TODO: - self - } -} - -extension ProjectionTransform: Equatable {} - -extension ProjectionTransform { - @inline(__always) - @inlinable - func dot(_ a: (CGFloat, CGFloat, CGFloat), _ b: (CGFloat, CGFloat, CGFloat)) -> CGFloat { - a.0 * b.0 + a.1 * b.1 + a.2 * b.2 - } - - @inlinable - public func concatenating(_ rhs: ProjectionTransform) -> ProjectionTransform { - var m = ProjectionTransform() - m.m11 = dot((m11, m12, m13), (rhs.m11, rhs.m21, rhs.m31)) - m.m12 = dot((m11, m12, m13), (rhs.m12, rhs.m22, rhs.m32)) - m.m13 = dot((m11, m12, m13), (rhs.m13, rhs.m23, rhs.m33)) - m.m21 = dot((m21, m22, m23), (rhs.m11, rhs.m21, rhs.m31)) - m.m22 = dot((m21, m22, m23), (rhs.m12, rhs.m22, rhs.m32)) - m.m23 = dot((m21, m22, m23), (rhs.m13, rhs.m23, rhs.m33)) - m.m31 = dot((m31, m32, m33), (rhs.m11, rhs.m21, rhs.m31)) - m.m32 = dot((m31, m32, m33), (rhs.m12, rhs.m22, rhs.m32)) - m.m33 = dot((m31, m32, m33), (rhs.m13, rhs.m23, rhs.m33)) - return m - } -} - -extension CGPoint { - public func applying(_: ProjectionTransform) -> CGPoint { - // TODO: - self - } -} - -struct CodableProjectionTransform { - var base: ProjectionTransform -} diff --git a/Sources/OpenSwiftUICore/Layout/Transform/SizeThatFitsObserver.swift b/Sources/OpenSwiftUICore/Layout/Transform/SizeThatFitsObserver.swift deleted file mode 100644 index 5eaf09e3..00000000 --- a/Sources/OpenSwiftUICore/Layout/Transform/SizeThatFitsObserver.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// SizeThatFitsObserver.swift -// OpenSwiftUI -// -// Audited for iOS 15.5 -// Status: Complete - -import Foundation - -package struct SizeThatFitsObserver { - var proposal: _ProposedSize - var callback: (CGSize, CGSize) -> Void -} diff --git a/Sources/OpenSwiftUICore/Layout/Transform/ViewTransform.swift b/Sources/OpenSwiftUICore/Layout/Transform/ViewTransform.swift deleted file mode 100644 index 2d8e9715..00000000 --- a/Sources/OpenSwiftUICore/Layout/Transform/ViewTransform.swift +++ /dev/null @@ -1,58 +0,0 @@ -// ID: CE19A3CEA6B9730579C42CE4C3071E74 - -import Foundation - -package struct ViewTransform { - private var chunks: ContiguousArray - var positionAdjustment: CGSize - - package init() { - self.chunks = [] - self.positionAdjustment = .zero - } -} - -extension ViewTransform { - private class Chunk { - var tags: [Tag] = [] - var values: [CGFloat] = [] - var spaces: [AnyHashable] = [] - - enum Tag: UInt8 { - case translation - case affine - case affine_inverse - case projection - case projection_inverse - case space - case sized_space - case scroll_layout - } - - func appendTranslation(_ translation: CGSize) { - tags.append(.translation) - values.append(translation.width) - values.append(translation.height) - - } - } -} - -extension ViewTransform { - enum Item/*: Codable*/ { - case translation(CGSize) - #if canImport(Darwin) - case affineTransform(CGAffineTransform, inverse: Bool) - #endif - case projectionTransform(ProjectionTransform, inverse: Bool) - case coordinateSpace(name: AnyHashable) - case sizedSpace(name: AnyHashable, size: CGSize) - // case scrollLayout(_ScrollLayout) - - enum CodingKeys: CodingKey { - case translation - case affineTransform - case projection - } - } -} diff --git a/Sources/OpenSwiftUICore/Layout/Transform/ViewDimensions.swift b/Sources/OpenSwiftUICore/Layout/View/ViewDimensions.swift similarity index 100% rename from Sources/OpenSwiftUICore/Layout/Transform/ViewDimensions.swift rename to Sources/OpenSwiftUICore/Layout/View/ViewDimensions.swift diff --git a/Sources/OpenSwiftUICore/Layout/Transform/ViewFrame.swift b/Sources/OpenSwiftUICore/Layout/View/ViewFrame.swift similarity index 100% rename from Sources/OpenSwiftUICore/Layout/Transform/ViewFrame.swift rename to Sources/OpenSwiftUICore/Layout/View/ViewFrame.swift diff --git a/Sources/OpenSwiftUICore/Layout/Transform/ViewGeometry.swift b/Sources/OpenSwiftUICore/Layout/View/ViewGeometry.swift similarity index 100% rename from Sources/OpenSwiftUICore/Layout/Transform/ViewGeometry.swift rename to Sources/OpenSwiftUICore/Layout/View/ViewGeometry.swift diff --git a/Sources/OpenSwiftUICore/Layout/Transform/ViewOrigin.swift b/Sources/OpenSwiftUICore/Layout/View/ViewOrigin.swift similarity index 100% rename from Sources/OpenSwiftUICore/Layout/Transform/ViewOrigin.swift rename to Sources/OpenSwiftUICore/Layout/View/ViewOrigin.swift diff --git a/Sources/OpenSwiftUICore/Layout/Transform/ViewSize.swift b/Sources/OpenSwiftUICore/Layout/View/ViewSize.swift similarity index 100% rename from Sources/OpenSwiftUICore/Layout/Transform/ViewSize.swift rename to Sources/OpenSwiftUICore/Layout/View/ViewSize.swift diff --git a/Sources/OpenSwiftUICore/Layout/View/ViewTransform.swift b/Sources/OpenSwiftUICore/Layout/View/ViewTransform.swift new file mode 100644 index 00000000..cd43f1c1 --- /dev/null +++ b/Sources/OpenSwiftUICore/Layout/View/ViewTransform.swift @@ -0,0 +1,765 @@ +// +// ViewTransform.swift +// OpenSwiftUICore +// +// Audited for iOS 18.0 +// Status: WIP +// ID: CE19A3CEA6B9730579C42CE4C3071E74 (SwiftUI) +// ID: 1CC2FE016A82CF91549A64E942CE8ED4 (SwiftUICore) + +package import Foundation + +@_spi(ForOpenSwiftUIOnly) +public struct ViewTransform: Equatable, CustomStringConvertible { + package enum Conversion { + case rootToSpace(CoordinateSpace) + case spaceToRoot(CoordinateSpace) + case localToSpace(CoordinateSpace) + case spaceToLocal(CoordinateSpace) + case spaceToSpace(CoordinateSpace, CoordinateSpace) + + package static func globalToSpace(_ space: CoordinateSpace) -> ViewTransform.Conversion { + .spaceToSpace(.global, space) + } + + package static func spaceToGlobal(_ space: CoordinateSpace) -> ViewTransform.Conversion { + .spaceToSpace(space, .global) + } + + // FIXME + @inline(__always) + func normalized() -> Conversion { + guard case let .spaceToSpace(space1, space2) = self else { return self } + if case .local = space1 { + return .localToSpace(space2) + } else if case .local = space2 { + return .spaceToLocal(space1) + } else if .root == space1 { + return .rootToSpace(space2) + } else if .root == space2 { + return .spaceToRoot(space1) + } else { + return self + } + } + } + + package enum Item: Equatable { + case translation(CGSize) + case affineTransform(CGAffineTransform, inverse: Bool) + case projectionTransform(ProjectionTransform, inverse: Bool) + case coordinateSpace(CoordinateSpace.Name) + case sizedSpace(CoordinateSpace.Name, size: CGSize) + case scrollGeometry(ViewTransform.ScrollGeometryItem) + + fileprivate func apply(to rect: inout CGRect?, name: CoordinateSpace.Name) { + switch self { + case let .translation(offset): + rect?.origin += offset + case let .affineTransform(matrix, inverse): + #if canImport(CoreGraphics) + guard matrix.isRectilinear else { + rect = nil + break + } + let transform = inverse ? matrix.inverted() : matrix + if var rect { + rect = rect.applying(transform) + } + #else + preconditionFailure("CGAffineTransform+applying is not available on this platform") + #endif + case .projectionTransform: + rect = nil + case .coordinateSpace: + break + case let .sizedSpace(spaceName, size): + guard spaceName == name else { break } + rect = CGRect(origin: .zero, size: size) + case .scrollGeometry: + break + } + } + + fileprivate func apply(to geometry: inout ScrollGeometry?, allowUnclipped: Bool) { + switch self { + case let .translation(offset): + geometry?.contentOffset += offset + case let .affineTransform(matrix, inverse): + #if canImport(CoreGraphics) + guard matrix.isRectilinear else { + geometry = nil + break + } + let transform = inverse ? matrix.inverted() : matrix + if var geometry { + geometry.contentOffset = geometry.contentOffset.applying(transform) + geometry.containerSize = geometry.containerSize.applying(transform) + } + #else + preconditionFailure("CGAffineTransform+applying is not available on this platform") + #endif + case .projectionTransform: + geometry = nil + case .coordinateSpace: + break + case .sizedSpace: + break + case let .scrollGeometry(geometryItem): + guard geometryItem.isClipped || allowUnclipped else { + break + } + geometry = geometryItem.base + } + } + } + + package struct ScrollGeometryItem: ViewTransformElement { + var base: ScrollGeometry + var isClipped: Bool + + func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + body(.scrollGeometry(self), &stop) + } + + package static func == (lhs: ViewTransform.ScrollGeometryItem, rhs: ViewTransform.ScrollGeometryItem) -> Bool { + lhs.base == rhs.base && lhs.isClipped == rhs.isClipped + } + } + + private var head: AnyElement? + package private(set) var positionAdjustment: CGSize + private var pendingTranslation: CGSize + + package init() { + self.head = nil + self.positionAdjustment = .zero + self.pendingTranslation = .zero + } + + package var isEmpty: Bool { + head == nil && pendingTranslation == .zero + } + + public static func == (lhs: ViewTransform, rhs: ViewTransform) -> Bool { + guard lhs.positionAdjustment == rhs.positionAdjustment && lhs.pendingTranslation == rhs.pendingTranslation else { return false } + guard let lhsHead = lhs.head, let rhsHead = rhs.head else { return lhs.head == nil && rhs.head == nil } + guard lhsHead.depth == rhsHead.depth else { return false } + + var lhsNode: AnyElement? = lhsHead + var rhsNode: AnyElement? = rhsHead + + while let lhsElement = lhsNode, let rhsElement = rhsNode { + guard lhsElement !== rhsElement else { return true } + guard lhsElement.isEqual(to: rhsElement) else { return false } + lhsNode = lhsElement.next + rhsNode = rhsElement.next + } + return lhsNode == nil && rhsNode == nil + } + + package mutating func append(movingContentsOf elements: inout UnsafeBuffer) { + head = BufferedElement(next: head, translation: pendingTranslation, elements: elements) + pendingTranslation = .zero + elements = UnsafeBuffer() + } + + package mutating func appendPosition(_ position: CGPoint) { + let adjustedPosition = position - positionAdjustment + pendingTranslation = pendingTranslation - CGSize(adjustedPosition) + positionAdjustment = CGSize(position) + } + + package func withPosition(_ position: CGPoint) -> ViewTransform { + var copy = self + copy.appendPosition(position) + return copy + } + + package mutating func appendPosition(_ position: CGPoint, scale: CGFloat) { + let adjustedPosition = position - positionAdjustment + pendingTranslation = pendingTranslation - CGSize(adjustedPosition) + positionAdjustment = CGSize(position) * scale + } + + package mutating func resetPosition(_ position: CGPoint) { + let adjustedPosition = position - positionAdjustment + pendingTranslation = pendingTranslation - CGSize(adjustedPosition) + positionAdjustment = .zero + } + + package mutating func setPositionAdjustment(_ offset: CGSize) { + positionAdjustment = offset + } + + package mutating func appendTranslation(_ size: CGSize) { + pendingTranslation += size + } + + package mutating func appendAffineTransform(_ matrix: CGAffineTransform, inverse: Bool) { + if matrix.isTranslation { + let translation = CGSize(width: matrix.tx, height: matrix.ty) + appendTranslation(inverse ? -translation : translation) + } else { + head = Element(next: head, translation: pendingTranslation, element: AffineTransformElement(matrix: matrix, inverse: inverse)) + pendingTranslation = .zero + } + } + + package mutating func appendProjectionTransform(_ matrix: ProjectionTransform, inverse: Bool) { + if matrix.isAffine { + appendAffineTransform(CGAffineTransform(matrix), inverse: inverse) + } else { + head = Element(next: head, translation: pendingTranslation, element: ProjectionTransformElement(matrix: matrix, inverse: inverse)) + pendingTranslation = .zero + } + } + + package mutating func appendCoordinateSpace(name: AnyHashable) { + head = Element(next: head, translation: pendingTranslation, element: CoordinateSpaceElement(name: name)) + pendingTranslation = .zero + } + + package mutating func appendCoordinateSpace(id: CoordinateSpace.ID) { + head = Element(next: head, translation: pendingTranslation, element: CoordinateSpaceIDElement(id: id)) + pendingTranslation = .zero + } + + package mutating func appendSizedSpace(name: AnyHashable, size: CGSize) { + head = Element(next: head, translation: pendingTranslation, element: SizedSpaceElement(name: name, size: size)) + pendingTranslation = .zero + } + + package mutating func appendSizedSpace(id: CoordinateSpace.ID, size: CGSize) { + head = Element(next: head, translation: pendingTranslation, element: SizedSpaceIDElement(id: id, size: size)) + pendingTranslation = .zero + } + + package mutating func appendScrollGeometry(_ geometry: ScrollGeometry, isClipped: Bool) { + head = Element(next: head, translation: pendingTranslation, element: ScrollGeometryItem(base: geometry, isClipped: isClipped)) + pendingTranslation = .zero + } + + package func forEach(inverted: Bool, _ body: (Item, inout Bool) -> Void) { + guard let head else { return } + var stop = false + if inverted { + if pendingTranslation != .zero { + body(.translation(pendingTranslation), &stop) + if stop { return } + } + var element: AnyElement = head + repeat { + element.forEach(inverted: true, stop: &stop, body) + if stop { return } + guard let next = element.next else { break } + element = next + } while true + } else { + withUnsafeTemporaryAllocation( + of: AnyElement.self, + capacity: head.depth + ) { bufferPointer in + bufferPointer.initializeElement(at: 0, to: head) + var element = head + var index = 0 + while let next = element.next { + bufferPointer.initializeElement(at: index, to: next) + element = next + index &+= 1 + } + for element in bufferPointer.reversed() { + element.forEach(inverted: false, stop: &stop, body) + if stop { return } + } + if pendingTranslation != .zero { + body(.translation(pendingTranslation), &stop) + } + } + } + } + + package func forEach(_ body: (ViewTransform.Item, inout Bool) -> Void) { + forEach(inverted: false, body) + } + + private func spaceBeforeSpace(_ space1: CoordinateSpace, _ space2: CoordinateSpace) -> Bool { + if case .global = space1 { + return true + } else if case .local = space1 { + return false + } else if case .global = space2 { + return false + } else if case .local = space2 { + return true + } else { + forEach(inverted: false) { item, stop in + // TODO + } + preconditionFailure("TODO") + } + } + + package func convert(_ conversion: ViewTransform.Conversion, _ body: (ViewTransform.Item) -> Void) { + guard !isEmpty else { return } + preconditionFailure("TODO") + } + + package func convert(_ conversion: ViewTransform.Conversion, points: inout [CGPoint]) { + guard !isEmpty else { return } + preconditionFailure("TODO") + } + + package func convert(_ conversion: ViewTransform.Conversion, point: CGPoint) -> CGPoint { + guard !isEmpty else { return point } + preconditionFailure("TODO") + } + + package var containingScrollGeometry: ScrollGeometry? { + preconditionFailure("TODO") + } + + package var nearestScrollGeometry: ScrollGeometry? { + preconditionFailure("TODO") + } + + package func containingSizedCoordinateSpace(name: CoordinateSpace.Name) -> CGRect? { + preconditionFailure("TODO") + } + + public var description: String { + var descriptionArray = pendingTranslation == .zero ? [] : [String(describing: pendingTranslation)] + var element = head + while let current = element { + if let description = current.description { + descriptionArray.append(description) + } + element = current.next + } + return descriptionArray.reversed().joined(separator: "; ") + } +} + +@_spi(ForOpenSwiftUIOnly) +@available(*, unavailable) +extension ViewTransform: Sendable {} + +@_spi(ForOpenSwiftUIOnly) +extension ViewTransform { + package struct UnsafeBuffer: Equatable { + var contents: UnsafeHeterogeneousBuffer + + typealias Element = UnsafeHeterogeneousBuffer.Element + + package init() { + contents = .init() + } + + package mutating func destroy() { + contents.destroy() + } + + package mutating func appendTranslation(_ size: CGSize) { + guard size != .zero else { return } + contents.append(TranslationElement(offset: size), vtable: _VTable.self) + } + + package mutating func appendAffineTransform(_ matrix: CGAffineTransform, inverse: Bool) { + if matrix.isTranslation { + let tranlation = CGSize(width: matrix.tx, height: matrix.ty) + appendTranslation(inverse ? -tranlation : tranlation) + } else { + contents.append(AffineTransformElement(matrix: matrix, inverse: inverse), vtable: _VTable.self) + } + } + + package mutating func appendProjectionTransform(_ matrix: ProjectionTransform, inverse: Bool) { + if matrix.isAffine { + appendAffineTransform(CGAffineTransform(matrix), inverse: inverse) + } else { + contents.append(ProjectionTransformElement(matrix: matrix, inverse: inverse), vtable: _VTable.self) + } + } + + package mutating func appendCoordinateSpace(id: CoordinateSpace.ID) { + contents.append(CoordinateSpaceIDElement(id: id), vtable: _VTable.self) + } + + package mutating func appendSizedSpace(id: CoordinateSpace.ID, size: CGSize) { + contents.append(SizedSpaceIDElement(id: id, size: size), vtable: _VTable.self) + } + + package mutating func appendScrollGeometry(_ geometry: ScrollGeometry, isClipped: Bool) { + contents.append(ScrollGeometryItem(base: geometry, isClipped: isClipped), vtable: _VTable.self) + } + + package static func == (lhs: ViewTransform.UnsafeBuffer, rhs: ViewTransform.UnsafeBuffer) -> Bool { + guard lhs.contents.count == rhs.contents.count else { return false } + guard lhs.contents.count > 0 else { return true } + for index in lhs.contents.indices { + let lhsElement = lhs.contents[index] + let rhsElement = rhs.contents[index] + guard lhsElement.item.pointee.vtable == rhsElement.item.pointee.vtable, + lhsElement.vtable(as: VTable.self).equal(lhsElement, rhsElement) + else { return false } + } + return true + } + + fileprivate func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + if inverted { + withUnsafeTemporaryAllocation( + of: UnsafeBuffer.Element.self, + capacity: contents.count + ) { bufferPointer in + for (index, element) in contents.enumerated() { + bufferPointer.initializeElement(at: index, to: element) + } + for element in bufferPointer.reversed() { + element.vtable(as: VTable.self).forEach(elt: element, inverted: true, stop: &stop, body) + if stop { return } + } + } + } else { + for element in contents { + element.vtable(as: VTable.self).forEach(elt: element, inverted: false, stop: &stop, body) + if stop { return } + } + } + } + + fileprivate var description: String { + let contentsDescription = contents.map { element in + element.vtable(as: VTable.self).description(elt: element) + } + return "[\(contentsDescription.joined(separator: ", "))]" + } + + // MARK: - ViewTransform.UnsafeBuffer.VTable + + private class VTable: _UnsafeHeterogeneousBuffer_VTable { + class func forEach(elt: _UnsafeHeterogeneousBuffer_Element, inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) {} + class func description(elt: _UnsafeHeterogeneousBuffer_Element) -> String { "" } + class func equal(_ lhs: _UnsafeHeterogeneousBuffer_Element, _ rhs: _UnsafeHeterogeneousBuffer_Element) -> Bool { false } + } + + private final class _VTable: VTable where Element: ViewTransformElement { + override class func hasType(_ type: T.Type) -> Bool { + Element.self == type + } + + override class func moveInitialize(elt: _UnsafeHeterogeneousBuffer_Element, from: _UnsafeHeterogeneousBuffer_Element) { + let dest = elt.body(as: Element.self) + let source = from.body(as: Element.self) + dest.initialize(to: source.move()) + } + + override class func deinitialize(elt: UnsafeHeterogeneousBuffer.Element) { + elt.body(as: Element.self).deinitialize(count: 1) + } + + override class func forEach(elt: _UnsafeHeterogeneousBuffer_Element, inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + elt.body(as: Element.self).pointee.forEach(inverted: inverted, stop: &stop, body) + } + + override class func description(elt: _UnsafeHeterogeneousBuffer_Element) -> String { + String(describing: elt.body(as: Element.self).pointee) + } + + override class func equal(_ lhs: _UnsafeHeterogeneousBuffer_Element, _ rhs: _UnsafeHeterogeneousBuffer_Element) -> Bool { + let lhs = lhs.body(as: Element.self) + let rhs = rhs.body(as: Element.self) + return lhs.pointee == rhs.pointee + } + } + } +} + +// MARK: - ViewTransformElement + +private protocol ViewTransformElement: Equatable { + func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) +} + +private struct TranslationElement: ViewTransformElement { + var offset: CGSize + + func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + body(.translation(inverted ? -offset : offset), &stop) + } +} + +private struct AffineTransformElement: ViewTransformElement { + var matrix: CGAffineTransform + var inverse: Bool + + func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + body(.affineTransform(matrix, inverse: inverse), &stop) + } +} + +private struct ProjectionTransformElement: ViewTransformElement { + var matrix: ProjectionTransform + var inverse: Bool + + func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + body(.projectionTransform(matrix, inverse: inverse), &stop) + } +} + +private struct CoordinateSpaceElement: ViewTransformElement { + var name: AnyHashable + + func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + body(.coordinateSpace(.name(name)), &stop) + } +} + +private struct CoordinateSpaceIDElement: ViewTransformElement { + var id: CoordinateSpace.ID + + func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + body(.coordinateSpace(.id(id)), &stop) + } +} + +private struct SizedSpaceElement: ViewTransformElement { + var name: AnyHashable + var size: CGSize + + func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + body(.sizedSpace(.name(name), size: size), &stop) + } +} + +private struct SizedSpaceIDElement: ViewTransformElement { + var id: CoordinateSpace.ID + var size: CGSize + + func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + body(.sizedSpace(.id(id), size: size), &stop) + } +} + +// MARK: - AnyElement + +private class AnyElement { + var next: AnyElement? + let depth: Int + + init(next: AnyElement?) { + self.next = next + self.depth = (next?.depth ?? 0) + 1 + } + + func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) {} + func isEqual(to other: AnyElement) -> Bool { false } + var description: String? { nil } +} + +private class Element: AnyElement where Value: ViewTransformElement { + let translation: CGSize + let element: Value + + init(next: AnyElement?, translation: CGSize, element: Value) { + self.translation = translation + self.element = element + super.init(next: next) + } + + override func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + if !inverted, translation != .zero { + body(.translation(translation), &stop) + if stop { return } + } + element.forEach(inverted: inverted, stop: &stop, body) + if stop { return } + if inverted, translation != .zero { + body(.translation(-translation), &stop) + } + } + + override func isEqual(to other: AnyElement) -> Bool { + guard let otherElement = other as? Element else { return false } + return translation == otherElement.translation && element == otherElement.element + } + + override var description: String? { + let description = String(describing: element) + if translation == .zero { + return description + } else { + return "(\(translation), \(description))" + } + } +} + +private class BufferedElement: AnyElement { + let translation: CGSize + var elements: ViewTransform.UnsafeBuffer + + init(next: AnyElement?, translation: CGSize, elements: ViewTransform.UnsafeBuffer) { + self.translation = translation + self.elements = elements + super.init(next: next) + } + + override func forEach(inverted: Bool, stop: inout Bool, _ body: (ViewTransform.Item, inout Bool) -> ()) { + if !inverted, translation != .zero { + body(.translation(translation), &stop) + if stop { return } + } + elements.forEach(inverted: inverted, stop: &stop, body) + if stop { return } + if inverted, translation != .zero { + body(.translation(-translation), &stop) + } + } + + override func isEqual(to other: AnyElement) -> Bool { + guard let otherElement = other as? BufferedElement else { return false } + return translation == otherElement.translation && elements == otherElement.elements + } + + override var description: String? { + let description = elements.description + if translation == .zero { + return description + } else { + return "(\(translation), \(description))" + } + } +} + +// MARK: - ViewTransformable + +private protocol ApplyViewTransform { + mutating func applyTransform(item: ViewTransform.Item) +} + +extension ApplyViewTransform { + mutating func convert(to space: CoordinateSpace, transform: ViewTransform) { + transform.convert(.localToSpace(space)) { item in + applyTransform(item: item) + } + } +} + +package protocol ViewTransformable { + mutating func convert(to space: CoordinateSpace, transform: ViewTransform) + mutating func convert(from space: CoordinateSpace, transform: ViewTransform) +} + +extension ViewTransformable where Self: ApplyViewTransform { + mutating func convert(to space: CoordinateSpace, transform: ViewTransform) { + transform.convert(.localToSpace(space)) { item in + applyTransform(item: item) + } + } + + mutating func convert(from space: CoordinateSpace, transform: ViewTransform) { + transform.convert(.spaceToLocal(space)) { item in + applyTransform(item: item) + } + } +} + +extension CGPoint: ApplyViewTransform, ViewTransformable { + package mutating func applyTransform(item: ViewTransform.Item) { + switch item { + case let .translation(offset): + self += offset + case let .affineTransform(matrix, inverse): + #if canImport(CoreGraphics) + if inverse { + if matrix.isTranslation { + self -= CGSize(width: matrix.tx, height: matrix.ty) + } else { + self = applying(matrix.inverted()) + } + } else { + self = applying(matrix) + } + #else + preconditionFailure("CGAffineTransform+applying is not available on this platform") + #endif + case let .projectionTransform(matrix, inverse): + self = inverse ? unapplying(matrix) : applying(matrix) + case .coordinateSpace, .sizedSpace, .scrollGeometry: + break + } + } + + package mutating func convert(to space: CoordinateSpace, transform: ViewTransform) { + self = transform.convert(.localToSpace(space), point: self) + } + + package mutating func convert(from space: CoordinateSpace, transform: ViewTransform) { + self = transform.convert(.spaceToLocal(space), point: self) + } +} + +extension [CGPoint]: ApplyViewTransform, ViewTransformable { + package mutating func applyTransform(item: ViewTransform.Item) { + switch item { + case let .translation(offset): + self = map { $0 + offset } + case let .affineTransform(matrix, inverse): + #if canImport(CoreGraphics) + let tranform = inverse ? matrix.inverted() : matrix + self = map { $0.applying(tranform) } + #else + preconditionFailure("CGAffineTransform+applying is not available on this platform") + #endif + case let .projectionTransform(matrix, inverse): + apply(matrix, inverse: inverse) + case .coordinateSpace, .sizedSpace, .scrollGeometry: + break + } + } + + package mutating func apply(_ m: ProjectionTransform, inverse: Bool) { + self = map { inverse ? $0.unapplying(m) : $0.applying(m) } + } + + package mutating func convert(to space: CoordinateSpace, transform: ViewTransform) { + transform.convert(.localToSpace(space), points: &self) + } + + package mutating func convert(from space: CoordinateSpace, transform: ViewTransform) { + transform.convert(.spaceToLocal(space), points: &self) + } +} + +extension CGRect: ViewTransformable { + package mutating func convert(to space: CoordinateSpace, transform: ViewTransform) { + guard !isNull else { return } + guard !isInfinite else { return } + var points = cornerPoints + points.convert(to: space, transform: transform) + self = CGRect(cornerPoints: points) + } + + package mutating func convert(from space: CoordinateSpace, transform: ViewTransform) { + guard !isNull else { return } + guard !isInfinite else { return } + var points = cornerPoints + points.convert(from: space, transform: transform) + self = CGRect(cornerPoints: points) + } + + package mutating func whileClippingToScrollViewsConvert(to space: CoordinateSpace, transform: ViewTransform) -> Bool { + guard !isNull else { return true } + guard !isInfinite else { return true } + transform.convert(.localToSpace(space)) { item in + // TODO + } + preconditionFailure("TODO") + } +} + +// TODO: Path + ViewTransformable +//extension Path: ViewTransformable { +// package mutating func convert(to space: CoordinateSpace, transform: ViewTransform) +// package mutating func convert(from space: CoordinateSpace, transform: ViewTransform) +//} diff --git a/Sources/OpenSwiftUICore/View/Debug/ViewDebug.swift b/Sources/OpenSwiftUICore/View/Debug/ViewDebug.swift index cf537176..3f408383 100644 --- a/Sources/OpenSwiftUICore/View/Debug/ViewDebug.swift +++ b/Sources/OpenSwiftUICore/View/Debug/ViewDebug.swift @@ -429,10 +429,50 @@ package protocol CustomViewDebugValueConvertible { @_spi(ForOpenSwiftUIOnly) extension ViewTransform.Item: Encodable { + enum CodingKeys: CodingKey { + case transform + case affineTransform + case projectionTransform + } + package func encode(to encoder: any Encoder) throws { - preconditionFailure("TODO") + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .translation(size): + try container.encode(size, forKey: .transform) + case let .affineTransform(affineTransform, inverse): + #if canImport(QuartzCore) + var transform3D = CATransform3DMakeAffineTransform(affineTransform) + if inverse { + transform3D = CATransform3DInvert(transform3D) + } + try container.encode(transform3D.elements, forKey: .affineTransform) + #else + preconditionFailure("CATransform3D is not available on this platform") + #endif + case let .projectionTransform(projectionTransform, inverse): + #if canImport(QuartzCore) + var transform3D = CATransform3D(projectionTransform) + if inverse { + transform3D = CATransform3DInvert(transform3D) + } + try container.encode(transform3D.elements, forKey: .projectionTransform) + #else + preconditionFailure("CATransform3D is not available on this platform") + #endif + default: + break + } + } +} + +#if canImport(QuartzCore) +extension CATransform3D { + fileprivate var elements: [CGFloat] { + [m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44] } } +#endif package protocol ValueWrapper { var wrappedValue: Any? { get } diff --git a/Tests/OpenSwiftUICompatibilityTests/Integration/UIKit/UIHostingController+Helper.swift b/Tests/OpenSwiftUICompatibilityTests/SwiftUI/Integration/UIKit/UIHostingController+Helper.swift similarity index 100% rename from Tests/OpenSwiftUICompatibilityTests/Integration/UIKit/UIHostingController+Helper.swift rename to Tests/OpenSwiftUICompatibilityTests/SwiftUI/Integration/UIKit/UIHostingController+Helper.swift diff --git a/Tests/OpenSwiftUICompatibilityTests/Integration/UIKit/UIHostingControllerTests.swift b/Tests/OpenSwiftUICompatibilityTests/SwiftUI/Integration/UIKit/UIHostingControllerTests.swift similarity index 100% rename from Tests/OpenSwiftUICompatibilityTests/Integration/UIKit/UIHostingControllerTests.swift rename to Tests/OpenSwiftUICompatibilityTests/SwiftUI/Integration/UIKit/UIHostingControllerTests.swift diff --git a/Tests/OpenSwiftUICompatibilityTests/View/Debug/ChangedBodyPropertyTests.swift b/Tests/OpenSwiftUICompatibilityTests/SwiftUI/View/Debug/ChangedBodyPropertyTests.swift similarity index 100% rename from Tests/OpenSwiftUICompatibilityTests/View/Debug/ChangedBodyPropertyTests.swift rename to Tests/OpenSwiftUICompatibilityTests/SwiftUI/View/Debug/ChangedBodyPropertyTests.swift diff --git a/Tests/OpenSwiftUICompatibilityTests/Data/Environment/EnvironmentValuesTest.swift b/Tests/OpenSwiftUICompatibilityTests/SwiftUICore/Data/Environment/EnvironmentValuesTest.swift similarity index 100% rename from Tests/OpenSwiftUICompatibilityTests/Data/Environment/EnvironmentValuesTest.swift rename to Tests/OpenSwiftUICompatibilityTests/SwiftUICore/Data/Environment/EnvironmentValuesTest.swift diff --git a/Tests/OpenSwiftUICompatibilityTests/View/Graphic/AngleTests.swift b/Tests/OpenSwiftUICompatibilityTests/SwiftUICore/Layout/Geometry/AngleTests.swift similarity index 100% rename from Tests/OpenSwiftUICompatibilityTests/View/Graphic/AngleTests.swift rename to Tests/OpenSwiftUICompatibilityTests/SwiftUICore/Layout/Geometry/AngleTests.swift diff --git a/Tests/OpenSwiftUICompatibilityTests/View/Graphic/AxisTests.swift b/Tests/OpenSwiftUICompatibilityTests/SwiftUICore/Layout/Geometry/AxisTests.swift similarity index 100% rename from Tests/OpenSwiftUICompatibilityTests/View/Graphic/AxisTests.swift rename to Tests/OpenSwiftUICompatibilityTests/SwiftUICore/Layout/Geometry/AxisTests.swift diff --git a/Tests/OpenSwiftUICompatibilityTests/View/Modifier/AppearanceActionModifierTests.swift b/Tests/OpenSwiftUICompatibilityTests/SwiftUICore/Modifier/ViewModifier/AppearanceActionModifierTests.swift similarity index 100% rename from Tests/OpenSwiftUICompatibilityTests/View/Modifier/AppearanceActionModifierTests.swift rename to Tests/OpenSwiftUICompatibilityTests/SwiftUICore/Modifier/ViewModifier/AppearanceActionModifierTests.swift diff --git a/Tests/OpenSwiftUICompatibilityTests/View/View/AnyViewTests.swift b/Tests/OpenSwiftUICompatibilityTests/SwiftUICore/View/AnyViewTests.swift similarity index 100% rename from Tests/OpenSwiftUICompatibilityTests/View/View/AnyViewTests.swift rename to Tests/OpenSwiftUICompatibilityTests/SwiftUICore/View/AnyViewTests.swift diff --git a/Tests/OpenSwiftUICoreTests/Layout/Edge/EdgeInsetsTests.swift b/Tests/OpenSwiftUICoreTests/Layout/Edge/EdgeInsetsTests.swift new file mode 100644 index 00000000..33c47b38 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Layout/Edge/EdgeInsetsTests.swift @@ -0,0 +1,213 @@ +// +// EdgeInsetsTests.swift +// OpenSwiftUICoreTests +// +// Audited for iOS 18.0 +// Status: Complete + +import Testing +import OpenSwiftUICore + +struct EdgeInsetsTests { + // MARK: - Initialization Tests + + @Test + func defaultInit() { + let insets = EdgeInsets() + #expect(insets.top == 0) + #expect(insets.leading == 0) + #expect(insets.bottom == 0) + #expect(insets.trailing == 0) + } + + @Test + func customInit() { + let insets = EdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4) + #expect(insets.top == 1) + #expect(insets.leading == 2) + #expect(insets.bottom == 3) + #expect(insets.trailing == 4) + } + + @Test + func valueAndEdgesInit() { + let allInsets = EdgeInsets(5, edges: .all) + #expect(allInsets.top == 5) + #expect(allInsets.leading == 5) + #expect(allInsets.bottom == 5) + #expect(allInsets.trailing == 5) + + let horizontalInsets = EdgeInsets(3, edges: .horizontal) + #expect(horizontalInsets.top == 0) + #expect(horizontalInsets.leading == 3) + #expect(horizontalInsets.bottom == 0) + #expect(horizontalInsets.trailing == 3) + + let verticalInsets = EdgeInsets(2, edges: .vertical) + #expect(verticalInsets.top == 2) + #expect(verticalInsets.leading == 0) + #expect(verticalInsets.bottom == 2) + #expect(verticalInsets.trailing == 0) + } + + // MARK: - Static Properties Tests + + @Test + func zero() { + let zero = EdgeInsets.zero + #expect(zero.top == 0) + #expect(zero.leading == 0) + #expect(zero.bottom == 0) + #expect(zero.trailing == 0) + } + + // MARK: - Subscript Tests + + @Test + func subscriptAccess() { + var insets = EdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4) + + // Get + #expect(insets[.top] == 1) + #expect(insets[.leading] == 2) + #expect(insets[.bottom] == 3) + #expect(insets[.trailing] == 4) + + // Set + insets[.top] = 5 + insets[.leading] = 6 + insets[.bottom] = 7 + insets[.trailing] = 8 + + #expect(insets.top == 5) + #expect(insets.leading == 6) + #expect(insets.bottom == 7) + #expect(insets.trailing == 8) + } + + // MARK: - Arithmetic Tests + + @Test + func addition() { + let insets1 = EdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4) + let insets2 = EdgeInsets(top: 5, leading: 6, bottom: 7, trailing: 8) + let sum = insets1.adding(insets2) + + #expect(sum.top == 6) + #expect(sum.leading == 8) + #expect(sum.bottom == 10) + #expect(sum.trailing == 12) + } + + @Test + func addingOptionalInsets() { + let insets = EdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4) + var optional = OptionalEdgeInsets() + optional.top = 5 + optional.leading = 6 + + let result = insets.adding(optional) + #expect(result.top == 6) + #expect(result.leading == 8) + #expect(result.bottom == 3) + #expect(result.trailing == 4) + } + + @Test + func merge() { + let insets = EdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4) + var optional = OptionalEdgeInsets() + optional.top = 5 + optional.leading = 6 + + let result = insets.merge(optional) + #expect(result.top == 5) + #expect(result.leading == 6) + #expect(result.bottom == 3) + #expect(result.trailing == 4) + } + + @Test + func negatedInsets() { + let insets = EdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4) + let negated = insets.negatedInsets + + #expect(negated.top == -1) + #expect(negated.leading == -2) + #expect(negated.bottom == -3) + #expect(negated.trailing == -4) + } + + @Test + func originOffset() { + let insets = EdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4) + let offset = insets.originOffset + + #expect(offset.width == 2) + #expect(offset.height == 1) + } + + // MARK: - Comparison Tests + + @Test + func formPointwiseMin() { + var insets = EdgeInsets(top: 3, leading: 4, bottom: 5, trailing: 6) + let other = EdgeInsets(top: 1, leading: 4, bottom: 7, trailing: 2) + + insets.formPointwiseMin(other) + #expect(insets.top == 1) + #expect(insets.leading == 4) + #expect(insets.bottom == 5) + #expect(insets.trailing == 2) + } + + @Test + func formPointwiseMax() { + var insets = EdgeInsets(top: 3, leading: 4, bottom: 5, trailing: 6) + let other = EdgeInsets(top: 1, leading: 4, bottom: 7, trailing: 2) + + insets.formPointwiseMax(other) + #expect(insets.top == 3) + #expect(insets.leading == 4) + #expect(insets.bottom == 7) + #expect(insets.trailing == 6) + } + + // MARK: - Layout Direction Tests + + @Test + func xFlipIfRightToLeft() { + var insets = EdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4) + + // Should not flip for left-to-right + insets.xFlipIfRightToLeft { .leftToRight } + #expect(insets.leading == 2) + #expect(insets.trailing == 4) + + // Should flip for right-to-left + insets.xFlipIfRightToLeft { .rightToLeft } + #expect(insets.leading == 4) + #expect(insets.trailing == 2) + } + + // MARK: - Animatable Tests + + @Test + func animatableData() { + let insets = EdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4) + let data = insets.animatableData + + #expect(data.first == 1) + #expect(data.second.first == 2) + #expect(data.second.second.first == 3) + #expect(data.second.second.second == 4) + + var newInsets = EdgeInsets() + newInsets.animatableData = data + + #expect(newInsets.top == 1) + #expect(newInsets.leading == 2) + #expect(newInsets.bottom == 3) + #expect(newInsets.trailing == 4) + } +} diff --git a/Tests/OpenSwiftUICoreTests/Layout/Geometry/ProjectionTransformTests.swift b/Tests/OpenSwiftUICoreTests/Layout/Geometry/ProjectionTransformTests.swift new file mode 100644 index 00000000..8a7bb0a4 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Layout/Geometry/ProjectionTransformTests.swift @@ -0,0 +1,285 @@ +// +// ProjectionTransformTests.swift +// OpenSwiftUICoreTests + +import Testing +import OpenSwiftUICore +import Numerics +import Foundation +#if canImport(QuartzCore) +import QuartzCore +#endif + +struct ProjectionTransformTests { + // MARK: - Initialization Tests + + @Test + func defaultInit() { + let transform = ProjectionTransform() + #expect(transform.m11 == 1.0) + #expect(transform.m12 == 0.0) + #expect(transform.m13 == 0.0) + #expect(transform.m21 == 0.0) + #expect(transform.m22 == 1.0) + #expect(transform.m23 == 0.0) + #expect(transform.m31 == 0.0) + #expect(transform.m32 == 0.0) + #expect(transform.m33 == 1.0) + } + + #if canImport(QuartzCore) + @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 == 2) + #expect(transform.m12 == 3) + #expect(transform.m21 == 4) + #expect(transform.m22 == 5) + #expect(transform.m31 == 6) + #expect(transform.m32 == 7) + #expect(transform.m13 == 0) + #expect(transform.m23 == 0) + #expect(transform.m33 == 1) + } + + @Test + func caTransform3DInit() { + let t3d = CATransform3DMakeTranslation(1, 2, 3) + let transform = ProjectionTransform(t3d) + #expect(transform.m11 == 1) + #expect(transform.m12 == 0) + #expect(transform.m13 == t3d.m14) + #expect(transform.m21 == 0) + #expect(transform.m22 == 1) + #expect(transform.m23 == t3d.m24) + #expect(transform.m31 == 1) + #expect(transform.m32 == 2) + #expect(transform.m33 == t3d.m44) + } + #endif + + // MARK: - Property Tests + + @Test + func isIdentity() { + let identity = ProjectionTransform() + #expect(identity.isIdentity) + + #if canImport(QuartzCore) + let nonIdentity = ProjectionTransform(CGAffineTransform(translationX: 1, y: 1)) + #expect(!nonIdentity.isIdentity) + #endif + } + + @Test + func isAffine() { + #if canImport(QuartzCore) + let affine = ProjectionTransform(CGAffineTransform.identity) + #expect(affine.isAffine) + #endif + + var nonAffine = ProjectionTransform() + nonAffine.m13 = 0.5 + #expect(!nonAffine.isAffine) + } + + #if canImport(QuartzCore) + @Test( + arguments: [ + // Affine transforms + (ProjectionTransform(CGAffineTransform(scaleX: 2, y: 3)), 6.0), + (ProjectionTransform(CGAffineTransform(rotationAngle: .pi/4)), 1.0), + // Non-affine transforms with zero determinant + ( + ProjectionTransform( + m11: 2, m12: 3, m13: 4, + m21: 4, m22: 6, m23: 8, + m31: 8, m32: 9, m33: 10 + ), + 0.0 + ), + // Non-affine transforms with non-zero determinant + ( + ProjectionTransform( + m11: 1, m12: 2, m13: 3, + m21: 0, m22: 1, m23: 4, + m31: 5, m32: 6, m33: 0 + ), + 1.0 + ), + ] + ) + func determinant(transform: ProjectionTransform, expectedDet: CGFloat) { + #expect(transform.determinant.isApproximatelyEqual(to: expectedDet)) + } + #else + @Test( + arguments: [ + // Non-affine transforms with non-zero determinant + ( + ProjectionTransform( + m11: 1, m12: 2, m13: 3, + m21: 0, m22: 1, m23: 4, + m31: 5, m32: 6, m33: 0 + ), + 1.0 + ), + ] + ) + func determinant(transform: ProjectionTransform, expectedDet: CGFloat) { + #expect(transform.determinant.isApproximatelyEqual(to: expectedDet)) + } + #endif + + + // MARK: - Matrix Operation Tests + + @Test + func invert() { + #if canImport(QuartzCore) + var transform = ProjectionTransform(CGAffineTransform(scaleX: 2, y: 2)) + let transformInvertResult = transform.invert() + #expect(transformInvertResult == true) + #expect(transform.m11 == 0.5) + #expect(transform.m22 == 0.5) + #endif + + var singular = ProjectionTransform() + singular.m11 = 0 + singular.m22 = 0 + let singularInvertResult = singular.invert() + #expect(singularInvertResult == false) + } + + @Test + func inverted() { + #if canImport(QuartzCore) + let transform = ProjectionTransform(CGAffineTransform(scaleX: 2, y: 2)) + let inverted = transform.inverted() + #expect(inverted.m11 == 0.5) + #expect(inverted.m22 == 0.5) + #endif + } + + @Test + func concatenating() { + #if canImport(QuartzCore) + let t1 = ProjectionTransform(CGAffineTransform(translationX: 1, y: 0)) + let t2 = ProjectionTransform(CGAffineTransform(translationX: 0, y: 2)) + let result = t1.concatenating(t2) + #expect(result.m31 == 1) + #expect(result.m32 == 2) + #endif + } + + // MARK: - Point Transform Tests + + @Test + func applyingToPoint() { + #if canImport(QuartzCore) + // Test affine transform + let transform = ProjectionTransform(CGAffineTransform(translationX: 1, y: 2)) + let point = CGPoint(x: 1, y: 1) + let transformed = point.applying(transform) + #expect(transformed.x == 2) + #expect(transformed.y == 3) + #endif + + // Test perspective transform + var perspective = ProjectionTransform() + perspective.m13 = 0.5 + let perspectivePoint = CGPoint(x: 2, y: 0).applying(perspective) + #expect(perspectivePoint.x != 2) // Point should be transformed by perspective + } + + @Test + func unapplyingToPoint() { + #if canImport(QuartzCore) + // Test with invertible transform + let transform = ProjectionTransform(CGAffineTransform(translationX: 1, y: 2)) + let point = CGPoint(x: 2, y: 3) + let untransformed = point.unapplying(transform) + #expect(untransformed.x == 1) + #expect(untransformed.y == 1) + #endif + + // Test with non-invertible transform + var singular = ProjectionTransform() + singular.m11 = 0 + singular.m22 = 0 + let originalPoint = CGPoint(x: 1, y: 1) + let result = originalPoint.unapplying(singular) + #expect(result == originalPoint) // Should return original point for non-invertible transform + } + + #if canImport(QuartzCore) + // MARK: - Conversion Tests + + @Test + func toCATransform3D() { + let projection = ProjectionTransform() + let transform3D = CATransform3D(projection) + #expect(transform3D.m11 == projection.m11) + #expect(transform3D.m12 == projection.m12) + #expect(transform3D.m14 == projection.m13) + #expect(transform3D.m21 == projection.m21) + #expect(transform3D.m22 == projection.m22) + #expect(transform3D.m24 == projection.m23) + #expect(transform3D.m41 == projection.m31) + #expect(transform3D.m42 == projection.m32) + #expect(transform3D.m44 == projection.m33) + } + + @Test + func toCGAffineTransform() { + let projection = ProjectionTransform() + let affine = CGAffineTransform(projection) + #expect(affine.a == projection.m11) + #expect(affine.b == projection.m12) + #expect(affine.c == projection.m21) + #expect(affine.d == projection.m22) + #expect(affine.tx == projection.m31) + #expect(affine.ty == projection.m32) + } + #endif + + // MARK: - ProtobufMessage Tests + + @Test( + arguments: [ + (ProjectionTransform(), ""), // Identity transform + // Scale transform + ( + ProjectionTransform( + m11: 2, m12: 0, m13: 0, + m21: 0, m22: 4, m23: 0, + m31: 0, m32: 0, m33: 1 + ), + "0d000000402d00008040" + ), + // Translation transform + ( + ProjectionTransform( + m11: 1, m12: 0, m13: 0, + m21: 0, m22: 1, m23: 0, + m31: 2, m32: 4, m33: 1 + ), + "3d000000404500008040" + ), + // Perspective transform + ( + ProjectionTransform( + m11: 1, m12: 0, m13: 0.5, + m21: 0, m22: 1, m23: 0.5, + m31: 0, m32: 0, m33: 1 + ), + "1d0000003f350000003f" + ) + ] + ) + func pbMessage(transform: ProjectionTransform, hexString: String) throws { + try transform.testPBEncoding(hexString: hexString) + try transform.testPBDecoding(hexString: hexString) + } +} diff --git a/Tests/OpenSwiftUICoreTests/Layout/Geometry/ProposedSizeTests.swift b/Tests/OpenSwiftUICoreTests/Layout/Geometry/ProposedSizeTests.swift new file mode 100644 index 00000000..6f657562 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Layout/Geometry/ProposedSizeTests.swift @@ -0,0 +1,171 @@ +// +// ProposedSizeTests.swift +// OpenSwiftUICoreTests + +import Foundation +import OpenSwiftUICore +import Testing + +struct ProposedSizeTests { + // MARK: - Static Properties Tests + + @Test + func staticProperties() { + let zero = _ProposedSize.zero + #expect(zero.width == 0) + #expect(zero.height == 0) + + let infinity = _ProposedSize.infinity + #expect(infinity.width == .infinity) + #expect(infinity.height == .infinity) + + let unspecified = _ProposedSize.unspecified + #expect(unspecified.width == nil) + #expect(unspecified.height == nil) + } + + // MARK: - Initialization Tests + + @Test + func defaultInit() { + let size = _ProposedSize() + #expect(size.width == nil) + #expect(size.height == nil) + } + + @Test + func initWithOptionals() { + let size1 = _ProposedSize(width: 100, height: 200) + #expect(size1.width == 100) + #expect(size1.height == 200) + + let size2 = _ProposedSize(width: nil, height: 200) + #expect(size2.width == nil) + #expect(size2.height == 200) + + let size3 = _ProposedSize(width: 100, height: nil) + #expect(size3.width == 100) + #expect(size3.height == nil) + } + + @Test + func initWithCGSize() { + let cgSize = CGSize(width: 100, height: 200) + let size = _ProposedSize(cgSize) + #expect(size.width == 100) + #expect(size.height == 200) + } + + @Test + func initWithAxisAndValues() { + let horizontal = _ProposedSize(100, in: .horizontal, by: 200) + #expect(horizontal.width == 100) + #expect(horizontal.height == 200) + + let vertical = _ProposedSize(100, in: .vertical, by: 200) + #expect(vertical.width == 200) + #expect(vertical.height == 100) + } + + // MARK: - Dimension Fixing Tests + + @Test + func fixingUnspecifiedDimensions() { + let size = _ProposedSize(width: nil, height: 200) + let defaults = CGSize(width: 50, height: 100) + + let fixed = size.fixingUnspecifiedDimensions(at: defaults) + #expect(fixed.width == 50) + #expect(fixed.height == 200) + + let defaultFixed = size.fixingUnspecifiedDimensions() + #expect(defaultFixed.width == 10.0) + #expect(defaultFixed.height == 200) + } + + // MARK: - Scaling Tests + + @Test + func scaling() { + let size = _ProposedSize(width: 100, height: 200) + let scaled = size.scaled(by: 2) + #expect(scaled.width == 200) + #expect(scaled.height == 400) + + let partialSize = _ProposedSize(width: nil, height: 200) + let partialScaled = partialSize.scaled(by: 2) + #expect(partialScaled.width == nil) + #expect(partialScaled.height == 400) + } + + // MARK: - Inset Tests + + @Test + func insetByEdgeInsets() { + let size = _ProposedSize(width: 100, height: 200) + let insets = EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20) + + let inset = size.inset(by: insets) + #expect(inset.width == 60) // 100 - (20 + 20) + #expect(inset.height == 180) // 200 - (10 + 10) + + // Test with nil dimensions + let partialSize = _ProposedSize(width: nil, height: 200) + let partialInset = partialSize.inset(by: insets) + #expect(partialInset.width == nil) + #expect(partialInset.height == 180) + } + + // MARK: - Axis Access Tests + + @Test + func axisSubscript() { + var size = _ProposedSize(width: 100, height: 200) + + // Get + #expect(size[.horizontal] == 100) + #expect(size[.vertical] == 200) + + // Set + size[.horizontal] = 300 + size[.vertical] = 400 + #expect(size.width == 300) + #expect(size.height == 400) + } + + // MARK: - CGSize Conversion Tests + + @Test + func cgSizeConversion() { + let size = _ProposedSize(width: 100, height: 200) + let cgSize = CGSize(size) + #expect(cgSize?.width == 100) + #expect(cgSize?.height == 200) + + let partialSize = _ProposedSize(width: nil, height: 200) + #expect(CGSize(partialSize) == nil) + } + + // MARK: - Hashable Tests + + @Test + func hashableConformance() { + let size1 = _ProposedSize(width: 100, height: 200) + let size2 = _ProposedSize(width: 100, height: 200) + let size3 = _ProposedSize(width: 200, height: 100) + + #expect(size1 == size2) + #expect(size1 != size3) + #expect(size1.hashValue != size3.hashValue) + + var hasher = Hasher() + size1.hash(into: &hasher) + let hash1 = hasher.finalize() + + hasher = Hasher() + size2.hash(into: &hasher) + let hash2 = hasher.finalize() + + #expect(hash1 == hash2) + } +} \ No newline at end of file diff --git a/Tests/OpenSwiftUICoreTests/Layout/Geometry/ScrollGeometryTests.swift b/Tests/OpenSwiftUICoreTests/Layout/Geometry/ScrollGeometryTests.swift new file mode 100644 index 00000000..fccaea03 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Layout/Geometry/ScrollGeometryTests.swift @@ -0,0 +1,143 @@ +// +// ScrollGeometryTests.swift +// OpenSwiftUICoreTests + +import Foundation +import OpenSwiftUICore +import Testing + +struct ScrollGeometryTests { + // MARK: - Initialization Tests + + @Test + func initialization() { + let geometry = ScrollGeometry( + contentOffset: .init(x: 10, y: 20), + contentSize: .init(width: 100, height: 200), + contentInsets: .init(top: 5, leading: 5, bottom: 5, trailing: 5), + containerSize: .init(width: 50, height: 100), + visibleRect: .init(x: 10, y: 20, width: 50, height: 100) + ) + + #expect(geometry.contentOffset == CGPoint(x: 10, y: 20)) + #expect(geometry.contentSize == CGSize(width: 100, height: 200)) + #expect(geometry.contentInsets == EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)) + #expect(geometry.containerSize == CGSize(width: 50, height: 100)) + #expect(geometry.visibleRect == CGRect(x: 10, y: 20, width: 50, height: 100)) + } + + // MARK: - Content Offset Tests + + @Test + func contentOffsetUpdate() { + var geometry = ScrollGeometry( + contentOffset: .zero, + contentSize: .init(width: 100, height: 200), + contentInsets: .zero, + containerSize: .init(width: 50, height: 100), + visibleRect: .init(origin: .zero, size: .init(width: 50, height: 100)) + ) + + geometry.contentOffset = CGPoint(x: 10, y: 20) + #expect(geometry.visibleRect.origin == CGPoint(x: 10, y: 20)) + } + + // MARK: - Translation Tests + + @Test + func translateWithinBounds() { + var geometry = ScrollGeometry( + contentOffset: .zero, + contentSize: .init(width: 100, height: 200), + contentInsets: .zero, + containerSize: .init(width: 50, height: 100), + visibleRect: .init(origin: .zero, size: .init(width: 50, height: 100)) + ) + + geometry.translate( + by: .init(width: 10, height: 20), + limit: .init(width: 100, height: 200) + ) + + #expect(geometry.contentOffset == CGPoint(x: 10, y: 20)) + } + + @Test + func translateBeyondBounds() { + var geometry = ScrollGeometry( + contentOffset: .zero, + contentSize: .init(width: 100, height: 200), + contentInsets: .zero, + containerSize: .init(width: 50, height: 100), + visibleRect: .init(origin: .zero, size: .init(width: 50, height: 100)) + ) + + geometry.translate( + by: .init(width: 200, height: 300), + limit: .init(width: 100, height: 200) + ) + + #expect(geometry.contentOffset.x.isApproximatelyEqual(to: 50.0)) + #expect(geometry.contentOffset.y.isApproximatelyEqual(to: 100.0)) + } + + // MARK: - Accessibility Tests + + @Test + func outsetForAXWithinLimit() { + var geometry = ScrollGeometry( + contentOffset: .init(x: 10, y: 20), + contentSize: .init(width: 100, height: 200), + contentInsets: .zero, + containerSize: .init(width: 50, height: 100), + visibleRect: .init(x: 10, y: 20, width: 50, height: 100) + ) + + let originalWidth = geometry.containerSize.width + let originalHeight = geometry.containerSize.height + + geometry.outsetForAX(limit: .init(width: 40, height: 80)) + + // Container size should not change as limit is smaller + #expect(geometry.containerSize.width == originalWidth) + #expect(geometry.containerSize.height == originalHeight) + } + + @Test + func outsetForAXBeyondLimit() { + var geometry = ScrollGeometry( + contentOffset: .init(x: 10, y: 20), + contentSize: .init(width: 100, height: 200), + contentInsets: .zero, + containerSize: .init(width: 50, height: 100), + visibleRect: .init(x: 10, y: 20, width: 50, height: 100) + ) + + geometry.outsetForAX(limit: .init(width: 200, height: 400)) + + // Container size should adjust based on limit + #expect(geometry.containerSize.width > 50) + #expect(geometry.containerSize.height > 100) + #expect(geometry.containerSize.width <= 200) + #expect(geometry.containerSize.height <= 400) + } + + // MARK: - Layout Direction Tests + + @Test + func applyLayoutDirection() { + var geometry = ScrollGeometry( + contentOffset: .init(x: 10, y: 20), + contentSize: .init(width: 100, height: 200), + contentInsets: .zero, + containerSize: .init(width: 50, height: 100), + visibleRect: .init(x: 10, y: 20, width: 50, height: 100) + ) + + let originalOffset = geometry.contentOffset + geometry.applyLayoutDirection(.rightToLeft, contentSize: nil) + + // Should adjust for RTL + #expect(geometry.contentOffset != originalOffset) + } +} diff --git a/Tests/OpenSwiftUICoreTests/Layout/Transform/ProposedSizeTests.swift b/Tests/OpenSwiftUICoreTests/Layout/Transform/ProposedSizeTests.swift deleted file mode 100644 index 2be1a49c..00000000 --- a/Tests/OpenSwiftUICoreTests/Layout/Transform/ProposedSizeTests.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// ProposedSizeTests.swift -// OpenSwiftUI -// -// Audited for iOS 15.5 -// Status: Complete - -import OpenGraphShims -@testable import OpenSwiftUICore -import Testing - -@Suite -struct ProposedSizeTests { - @Test - func unspecified() { - let size = _ProposedSize.unspecified - #expect(size.width == nil) - #expect(size.height == nil) - } - - @Test - func hashable() { - let size1 = _ProposedSize(width: 20, height: 30) - let size2 = _ProposedSize(width: 30, height: 20) - #expect(size1 != size2) - #expect(size1.hashValue != size2.hashValue) - } -} diff --git a/Tests/OpenSwiftUICoreTests/Layout/View/ViewTransformTests.swift b/Tests/OpenSwiftUICoreTests/Layout/View/ViewTransformTests.swift new file mode 100644 index 00000000..ad5219f0 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Layout/View/ViewTransformTests.swift @@ -0,0 +1,81 @@ +// +// ViewTransformTests.swift +// OpenSwiftUICoreTests + +import Foundation +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore +import Testing + +struct ViewTransformTests { + @Test + func conversion() { + #expect(MemoryLayout.size == 0x29) + #expect(MemoryLayout.size == 0x5A) + + let space = CoordinateSpace.named("test") + + do { + let globalToSpace = ViewTransform.Conversion.globalToSpace(space) + + guard case let .spaceToSpace(global, space) = globalToSpace else { + Issue.record("Expected .spaceToSpace, got \(globalToSpace)") + return + } + guard case let .named(name) = space, let name = name as? String else { + Issue.record("Expected .named, got \(space)") + return + } + #expect(name == "test") + #expect(global == .global) + } + + do { + let spaceToGlobal = ViewTransform.Conversion.spaceToGlobal(space) + + guard case let .spaceToSpace(space, global) = spaceToGlobal else { + Issue.record("Expected .spaceToSpace, got \(spaceToGlobal)") + return + } + guard case let .named(name) = space, let name = name as? String else { + Issue.record("Expected .named, got \(space)") + return + } + #expect(name == "test") + #expect(global == .global) + } + } + + @Test + func viewTransformDescription() { + #if canImport(CoreGraphics) + var transform = ViewTransform() + transform.appendTranslation(CGSize(width: 10, height: 10)) + #expect(transform.description == #""" + (10.0, 10.0) + """#) + transform.appendCoordinateSpace(name: "a") + #expect(transform.description == #""" + ((10.0, 10.0), CoordinateSpaceElement(name: AnyHashable("a"))) + """#) + transform.appendSizedSpace(name: "b", size: .init(width: 20, height: 20)) + #expect(transform.description == #""" + ((10.0, 10.0), CoordinateSpaceElement(name: AnyHashable("a"))); SizedSpaceElement(name: AnyHashable("b"), size: (20.0, 20.0)) + """#) + #else + var transform = ViewTransform() + transform.appendTranslation(CGSize(width: 10, height: 10)) + #expect(transform.description == #""" + CGSize(width: 10.0, height: 10.0) + """#) + transform.appendCoordinateSpace(name: "a") + #expect(transform.description == #""" + (CGSize(width: 10.0, height: 10.0), CoordinateSpaceElement(name: AnyHashable("a"))) + """#) + transform.appendSizedSpace(name: "b", size: .init(width: 20, height: 20)) + #expect(transform.description == #""" + (CGSize(width: 10.0, height: 10.0), CoordinateSpaceElement(name: AnyHashable("a"))); SizedSpaceElement(name: AnyHashable("b"), size: Foundation.CGSize(width: 20.0, height: 20.0)) + """#) + #endif + } +}