diff --git a/Sources/OpenSwiftUICore/View/Image/GraphicsImage.swift b/Sources/OpenSwiftUICore/View/Image/GraphicsImage.swift index 504ae2be5..e92c97bd8 100644 --- a/Sources/OpenSwiftUICore/View/Image/GraphicsImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/GraphicsImage.swift @@ -47,7 +47,7 @@ package struct GraphicsImage: Equatable, Sendable { package var size: CGSize { guard scale != .zero else { return .zero } - return unrotatedPixelSize.apply(orientation) * (1.0 / scale) + return pixelSize * (1.0 / scale) } package var pixelSize: CGSize { diff --git a/Sources/OpenSwiftUICore/View/Image/ImageLayer.swift b/Sources/OpenSwiftUICore/View/Image/ImageLayer.swift new file mode 100644 index 000000000..4257d5cba --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Image/ImageLayer.swift @@ -0,0 +1,255 @@ +// +// ImageLayer.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete - Blocked by AsyncUpdate +// ID: 854C382F3D9A82BFCF900A549E57F233 (SwiftUICore) + +#if canImport(Darwin) +package import QuartzCore +import QuartzCore_Private + +// MARK: - ImageLayer + +final package class ImageLayer: CALayer { + override dynamic package init() { + super.init() + } + + override dynamic package init(layer: Any) { + super.init(layer: layer) + } + + required dynamic package init?(coder: NSCoder) { + super.init(coder: coder) + } + + /// Updates the layer to display the given graphics image at the specified size. + /// + /// This method configures all relevant CALayer properties based on the GraphicsImage: + /// - Contents (CGImage, IOSurface, or rendered image) + /// - Background color (for solid color images) + /// - Contents scale and center (for proper resizing) + /// - Antialiasing and interpolation settings + /// - HDR/EDR settings for extended dynamic range content + /// + /// - Parameters: + /// - image: The graphics image to display. + /// - size: The target size for the layer content. + func update(image: GraphicsImage, size: CGSize) { + // Determine layer contents and background color based on image type + let layerContents: Any? + let bgColor: CGColor? + switch image.contents { + case let .cgImage(cgImage): + layerContents = cgImage + bgColor = nil + case let .ioSurface(surface): + layerContents = surface + bgColor = nil + case let .color(resolved): + layerContents = nil + bgColor = resolved.cgColor + case .vectorGlyph, .vectorLayer, .named: + layerContents = image.render(at: size, prefersMask: image.isTemplate) + bgColor = nil + case nil: + layerContents = nil + bgColor = nil + } + contents = layerContents + backgroundColor = bgColor + allowsEdgeAntialiasing = image.isAntialiased + guard contents != nil else { return } + + contentsScale = image.scale + + // Configure swizzle and tint for template images + if let maskColor = image.maskColor { + _CALayerSetSplatsContentsAlpha(self, true) + contentsMultiplyColor = maskColor.cgColor + } else { + _CALayerSetSplatsContentsAlpha(self, false) + contentsMultiplyColor = nil + } + + // Configure resizing behavior + let (centerRect, isTiled) = image.layerStretchInPixels(size: size) + contentsCenter = centerRect + contentsScaling = isTiled ? .repeat : .stretch + + // Configure interpolation filters + switch image.interpolation { + case .none: + minificationFilter = .nearest + magnificationFilter = .nearest + case .low, .medium: + minificationFilter = .linear + magnificationFilter = .linear + case .high: + // Use box filter for high-quality minification + minificationFilter = .box + magnificationFilter = .linear + } + + // Configure HDR/Extended Dynamic Range + let headroom: Image.Headroom + switch image.allowedDynamicRange?.storage { + case .standard, .none: headroom = .standard + case .constrainedHigh: headroom = min(image.headroom, .constrainedHigh) + case .high: headroom = min(image.headroom, .high) + } + + let wasUsingEDR = wantsExtendedDynamicRangeContent + let currentMaxEDR = wasUsingEDR ? contentsMaximumDesiredEDR : 1.0 + + // Enable EDR if needed + if headroom > .standard { + wantsExtendedDynamicRangeContent = true + } + contentsMaximumDesiredEDR = headroom.rawValue + + // Animate EDR transitions + let edrDelta = headroom.rawValue - currentMaxEDR + let shouldAnimate = (headroom > .standard || wasUsingEDR) && edrDelta != 0.0 + if shouldAnimate && isLinkedOnOrAfter(.v6) { + addEDRSpringAnimation(delta: edrDelta) + } + } + + @inline(__always) + private func addEDRSpringAnimation(delta: CGFloat) { + let animation = CASpringAnimation(keyPath: "contentsMaximumDesiredEDR") + animation.fromValue = NSNumber(value: -delta) + animation.toValue = NSNumber(value: 0.0) + animation.isAdditive = true + animation.duration = 3.0 + animation.mass = 2.0 + animation.stiffness = 19.7392 // 2π² + animation.damping = 12.5664 // 4π + animation.fillMode = .backwards + add(animation, forKey: nil) + } + +// func updateAsync( +// layer: DisplayList.ViewUpdater.AsyncLayer, +// oldImage: GraphicsImage, +// oldSize: CGSize, +// newImage: GraphicsImage, +// newSize: CGSize +// ) -> Bool { +// _openSwiftUIUnimplementedFailure() +// } +} + +// MARK: - GraphicsImage + LayerStretch + +extension GraphicsImage { + + /// Computes the contentsCenter rect and tiling mode for CALayer configuration. + /// + /// The returned rect is normalized to 0...1 coordinates for use with CALayer.contentsCenter. + /// For thin stretch regions (≤1 pixel), a minimal stretch line is used instead of tiling. + /// + /// - Parameter size: The target size for the layer. + /// - Returns: A tuple of (centerRect, isTiled) for CALayer configuration. + fileprivate func layerStretchInPixels(size: CGSize) -> (center: CGRect, tiled: Bool) { + let adjustedSize = size.apply(bitmapOrientation) + guard slicesAndTiles(at: adjustedSize) != nil else { + return (CGRect(x: 0, y: 0, width: 1, height: 1), false) + } + var isTiled = isTiledWhenStretchedToSize(adjustedSize) + let rect = contentStretchInPixels() + let stretchRect = rect.isNull ? .zero : rect + + let pxSize = unrotatedPixelSize + var x = stretchRect.origin.x + var y = stretchRect.origin.y + var width = stretchRect.size.width + var height = stretchRect.size.height + + let thinStretchOffset = 0.01 + let thinStretchSize = 0.02 + + let isThinStretchX: Bool + if x == 0, width == pxSize.width { + width = 1.0 + isThinStretchX = false + } else if isTiled { + x /= pxSize.width + width /= pxSize.width + isThinStretchX = false + } else { + width = max(0, width - 1) + x = (x + 0.5) / pxSize.width + if width <= 1 { + x -= thinStretchOffset / pxSize.width + width = thinStretchSize / pxSize.width + isThinStretchX = true + } else { + width /= pxSize.width + isThinStretchX = false + } + } + if y == 0, height == pxSize.height { + if isThinStretchX { + isTiled = false + } + height = 1.0 + } else if isTiled { + y /= pxSize.height + height /= pxSize.height + } else { + height = max(0, height - 1) + y = (y + 0.5) / pxSize.height + if height <= 1 { + isTiled = false + y -= thinStretchOffset / pxSize.height + height = thinStretchSize / pxSize.height + } else { + height /= pxSize.height + } + } + return (CGRect(x: x, y: y, width: width, height: height), isTiled) + } + + /// Determines if the image should be tiled when stretched to the given size. + /// + /// - Parameter targetSize: The target size to stretch to. + /// - Returns: `true` if tiling should be used; `false` otherwise. + fileprivate func isTiledWhenStretchedToSize(_ targetSize: CGSize) -> Bool { + guard let resizingInfo, resizingInfo.mode == .tile else { + return false + } + + let pointSize = size + let insets = resizingInfo.capInsets + let stretchWidth = pointSize.width - insets.leading - insets.trailing + if stretchWidth > 1 && pointSize.width != targetSize.width { + return true + } + let stretchHeight = pointSize.height - insets.top - insets.bottom + if stretchHeight > 1 && pointSize.height != targetSize.height { + return true + } + return false + } + + /// Returns the stretch rectangle in pixel coordinates. + /// + /// This is the center portion of the image that can be stretched or tiled, + /// excluding the cap insets. + fileprivate func contentStretchInPixels() -> CGRect { + // Get insets (zero if no resizingInfo) + let insets = resizingInfo?.capInsets ?? EdgeInsets() + let pixelSize = pixelSize + let insetRect = CGRect( + origin: .zero, + size: pixelSize + ).inset(by: insets * scale) + return insetRect.unapply(bitmapOrientation, in: pixelSize) + } +} + +#endif diff --git a/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CALayerPrivate.h b/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CALayerPrivate.h index 06b705e44..0b8475a0f 100644 --- a/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CALayerPrivate.h +++ b/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CALayerPrivate.h @@ -10,6 +10,12 @@ #import +/// A type representing contents swizzle modes for CALayer. +typedef NSString *CALayerContentsSwizzle NS_TYPED_ENUM; + +/// A type representing contents scaling modes for CALayer. +typedef NSString *CALayerContentsScaling NS_TYPED_ENUM; + @interface CALayer (OpenSwiftUI_SPI) @property (nonatomic, assign) BOOL allowsDisplayCompositing_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(allowsDisplayCompositing); @@ -17,10 +23,46 @@ @property (nonatomic, assign) BOOL allowsGroupBlending_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(allowsGroupBlending); @property (nonatomic, assign) uint64_t openSwiftUI_viewTestProperties; + +/// Private property to control contents alpha channel swizzling. +/// When set to kCALayerContentsSwizzleAAAA, the alpha channel is replicated to all channels. +/// When set to kCALayerContentsSwizzleRGBA, normal RGBA behavior is used. +@property (nullable, copy) CALayerContentsSwizzle contentsSwizzle; + +/// Private property to set a multiply color for layer contents. +/// This color is multiplied with the layer contents for tinting effects. +@property (nullable) CGColorRef contentsMultiplyColor; + +/// Private property to set the contents scaling mode. +/// Valid values include "stretch" and "repeat". +@property (nullable, copy) CALayerContentsScaling contentsScaling; + +/// Private property indicating if the layer wants extended dynamic range content. +@property (nonatomic) BOOL wantsExtendedDynamicRangeContent; + +/// Private property for the maximum desired EDR (Extended Dynamic Range) value. +@property (nonatomic) CGFloat contentsMaximumDesiredEDR; + @end -CA_EXTERN CALayerContentsFormat const kCAContentsFormatAutomatic OPENSWIFTUI_SWIFT_NAME(automatic); -CA_EXTERN CALayerContentsFormat const kCAContentsFormatA8 OPENSWIFTUI_SWIFT_NAME(A8); +CA_EXTERN CALayerContentsFormat _Nonnull const kCAContentsFormatAutomatic OPENSWIFTUI_SWIFT_NAME(automatic); +CA_EXTERN CALayerContentsFormat _Nonnull const kCAContentsFormatA8 OPENSWIFTUI_SWIFT_NAME(A8); + +/// Private constants for contents swizzling (from QuartzCore framework) +OPENSWIFTUI_EXPORT CALayerContentsSwizzle _Nonnull const kCALayerContentsSwizzleAAAA OPENSWIFTUI_SWIFT_NAME(AAAA); +OPENSWIFTUI_EXPORT CALayerContentsSwizzle _Nonnull const kCALayerContentsSwizzleRGBA OPENSWIFTUI_SWIFT_NAME(RGBA); + +/// Private constants for contents scaling mode +OPENSWIFTUI_EXPORT CALayerContentsScaling _Nonnull const kCAContentsScalingRepeat OPENSWIFTUI_SWIFT_NAME(repeat); +OPENSWIFTUI_EXPORT CALayerContentsScaling _Nonnull const kCAContentsScalingStretch OPENSWIFTUI_SWIFT_NAME(stretch); + +/// Private filter constant for box filtering (high quality downsampling) +OPENSWIFTUI_EXPORT CALayerContentsFilter _Nonnull const kCAFilterBox OPENSWIFTUI_SWIFT_NAME(box); + +/// Sets the contents swizzle mode for the layer. +/// When splatAlpha is true, uses kCALayerContentsSwizzleAAAA (alpha-only mode). +/// When splatAlpha is false, uses kCALayerContentsSwizzleRGBA (normal mode). +void _CALayerSetSplatsContentsAlpha(CALayer * _Nonnull layer, BOOL splatAlpha); #endif diff --git a/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CALayerPrivate.m b/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CALayerPrivate.m index a1595f062..b8f4e5212 100644 --- a/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CALayerPrivate.m +++ b/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CALayerPrivate.m @@ -10,6 +10,11 @@ #import @implementation CALayer (OpenSwiftUI_SPI) +@dynamic contentsSwizzle; +@dynamic contentsMultiplyColor; +@dynamic contentsScaling; +@dynamic wantsExtendedDynamicRangeContent; +@dynamic contentsMaximumDesiredEDR; - (BOOL)allowsDisplayCompositing_openswiftui_safe_wrapper { OPENSWIFTUI_SAFE_WRAPPER_IMP(BOOL, @"allowsDisplayCompositing", NO); @@ -46,4 +51,8 @@ - (void)setOpenSwiftUI_viewTestProperties:(uint64_t)properties { } @end +void _CALayerSetSplatsContentsAlpha(CALayer * _Nonnull layer, BOOL splatAlpha) { + layer.contentsSwizzle = splatAlpha ? kCALayerContentsSwizzleAAAA : kCALayerContentsSwizzleRGBA; +} + #endif /* */ diff --git a/Tests/OpenSwiftUICoreTests/View/Image/ImageLayerTests.swift b/Tests/OpenSwiftUICoreTests/View/Image/ImageLayerTests.swift new file mode 100644 index 000000000..2d14d3ff9 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/View/Image/ImageLayerTests.swift @@ -0,0 +1,166 @@ +// +// ImageLayerTests.swift +// OpenSwiftUICoreTests + +#if canImport(Darwin) +import OpenCoreGraphicsShims +@_spi(Private) +@testable +#if OPENSWIFTUI_ENABLE_PRIVATE_IMPORTS +@_private(sourceFile: "ImageLayer.swift") +#endif +import OpenSwiftUICore +import Testing + +// MARK: - GraphicsImage + LayerStretch Tests + +struct GraphicsImageLayerStretchTests { + // Helper to create a basic GraphicsImage for testing + private func makeImage( + scale: CGFloat = 1.0, + pixelSize: CGSize, + orientation: Image.Orientation = .up, + resizingInfo: Image.ResizingInfo? = nil + ) -> GraphicsImage { + GraphicsImage( + contents: nil, + scale: scale, + unrotatedPixelSize: pixelSize, + orientation: orientation, + isTemplate: false, + resizingInfo: resizingInfo + ) + } + + // MARK: - contentStretchInPixels Tests + + #if OPENSWIFTUI_ENABLE_PRIVATE_IMPORTS + @Test(arguments: [ + // (pixelSize, scale, capInsets, orientation, expected) + // No insets - full size + (CGSize(width: 100, height: 100), 1.0, EdgeInsets(), Image.Orientation.up, + CGRect(x: 0, y: 0, width: 100, height: 100)), + // With insets at scale 1 + (CGSize(width: 100, height: 100), 1.0, EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), Image.Orientation.up, + CGRect(x: 10, y: 10, width: 80, height: 80)), + // With insets at scale 2 + (CGSize(width: 200, height: 200), 2.0, EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), Image.Orientation.up, + CGRect(x: 20, y: 20, width: 160, height: 160)), + // Asymmetric insets + (CGSize(width: 100, height: 100), 1.0, EdgeInsets(top: 5, leading: 10, bottom: 15, trailing: 20), Image.Orientation.up, + CGRect(x: 10, y: 5, width: 70, height: 80)), + ]) + func contentStretchInPixels( + _ pixelSize: CGSize, + _ scale: CGFloat, + _ insets: EdgeInsets, + _ orientation: Image.Orientation, + _ expected: CGRect + ) { + let resizingInfo: Image.ResizingInfo? = insets == EdgeInsets() ? nil : Image.ResizingInfo(capInsets: insets, mode: .stretch) + let image = makeImage(scale: scale, pixelSize: pixelSize, orientation: orientation, resizingInfo: resizingInfo) + let result = image.contentStretchInPixels() + #expect(result == expected) + } + #endif + + // MARK: - isTiledWhenStretchedToSize Tests + + #if OPENSWIFTUI_ENABLE_PRIVATE_IMPORTS + @Test(arguments: [ + // (pixelSize, capInsets, mode, targetSize, expected) + // No resizingInfo (nil insets) → false + (CGSize(width: 100, height: 100), EdgeInsets?.none, Image.ResizingMode?.none, + CGSize(width: 200, height: 200), false), + // Stretch mode → false + (CGSize(width: 100, height: 100), EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), Image.ResizingMode.stretch, + CGSize(width: 200, height: 200), false), + // Tile mode, same size → false + (CGSize(width: 100, height: 100), EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), Image.ResizingMode.tile, + CGSize(width: 100, height: 100), false), + // Tile mode, different width → true + (CGSize(width: 100, height: 100), EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), Image.ResizingMode.tile, + CGSize(width: 200, height: 100), true), + // Tile mode, different height → true + (CGSize(width: 100, height: 100), EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), Image.ResizingMode.tile, + CGSize(width: 100, height: 200), true), + // Tile mode, thin stretch region → false + (CGSize(width: 100, height: 100), EdgeInsets(top: 49, leading: 49, bottom: 50, trailing: 50), Image.ResizingMode.tile, + CGSize(width: 200, height: 200), false), + ]) + func isTiledWhenStretchedToSize( + _ pixelSize: CGSize, + _ insets: EdgeInsets?, + _ mode: Image.ResizingMode?, + _ targetSize: CGSize, + _ expected: Bool + ) { + let resizingInfo: Image.ResizingInfo? = if let insets, let mode { + Image.ResizingInfo(capInsets: insets, mode: mode) + } else { + nil + } + let image = makeImage(pixelSize: pixelSize, resizingInfo: resizingInfo) + #expect(image.isTiledWhenStretchedToSize(targetSize) == expected) + } + #endif + + // MARK: - layerStretchInPixels Tests + + #if OPENSWIFTUI_ENABLE_PRIVATE_IMPORTS + @Test(arguments: [ + // (pixelSize, capInsets, mode, targetSize, expectedCenter, expectedTiled) + // No slicesAndTiles (no resizingInfo) → full rect + (CGSize(width: 100, height: 100), EdgeInsets?.none, Image.ResizingMode?.none, + CGSize(width: 100, height: 100), + CGRect(x: 0, y: 0, width: 1, height: 1), false), + // Full span X/Y → 1x1 + (CGSize(width: 100, height: 100), EdgeInsets.zero, Image.ResizingMode.stretch, + CGSize(width: 200, height: 200), + CGRect(x: 0, y: 0, width: 1, height: 1), false), + // Tiled mode → linear normalization + (CGSize(width: 100, height: 100), EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20), Image.ResizingMode.tile, + CGSize(width: 200, height: 200), + CGRect(x: 0.2, y: 0.1, width: 0.6, height: 0.8), true), + // Stretch mode → centered normalization + (CGSize(width: 100, height: 100), EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20), Image.ResizingMode.stretch, + CGSize(width: 200, height: 200), + CGRect(x: 0.205, y: 0.105, width: 0.59, height: 0.79), false), + // Thin stretch X → uses thin line + (CGSize(width: 100, height: 100), EdgeInsets(top: 10, leading: 49, bottom: 10, trailing: 49), Image.ResizingMode.stretch, + CGSize(width: 200, height: 200), + CGRect(x: 0.4949, y: 0.105, width: 0.0002, height: 0.79), false), + // Thin stretch Y → forces isTiled=false + (CGSize(width: 100, height: 100), EdgeInsets(top: 49, leading: 10, bottom: 49, trailing: 10), Image.ResizingMode.stretch, + CGSize(width: 200, height: 200), + CGRect(x: 0.105, y: 0.4949, width: 0.79, height: 0.0002), false), + // Thin stretch X + full height Y → isTiled=false + (CGSize(width: 100, height: 100), EdgeInsets(top: 0, leading: 49, bottom: 0, trailing: 49), Image.ResizingMode.stretch, + CGSize(width: 200, height: 200), + CGRect(x: 0.4949, y: 0, width: 0.0002, height: 1.0), false), + ]) + func layerStretchInPixels( + _ pixelSize: CGSize, + _ insets: EdgeInsets?, + _ mode: Image.ResizingMode?, + _ targetSize: CGSize, + _ expectedCenter: CGRect, + _ expectedTiled: Bool + ) { + let resizingInfo: Image.ResizingInfo? = if let insets, let mode { + Image.ResizingInfo(capInsets: insets, mode: mode) + } else { + nil + } + let image = makeImage(pixelSize: pixelSize, resizingInfo: resizingInfo) + let (center, tiled) = image.layerStretchInPixels(size: targetSize) + #expect(center.origin.x.isApproximatelyEqual(to: expectedCenter.origin.x)) + #expect(center.origin.y.isApproximatelyEqual(to: expectedCenter.origin.y)) + #expect(center.size.width.isApproximatelyEqual(to: expectedCenter.size.width)) + #expect(center.size.height.isApproximatelyEqual(to: expectedCenter.size.height)) + #expect(tiled == expectedTiled) + } + #endif +} + +#endif