Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/OpenSwiftUICore/View/Image/GraphicsImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
255 changes: 255 additions & 0 deletions Sources/OpenSwiftUICore/View/Image/ImageLayer.swift
Original file line number Diff line number Diff line change
@@ -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 }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update(image:size:) returns early when contents is nil, but that leaves prior image-related state (e.g. contentsMultiplyColor, contentsScaling, filters, EDR flags) untouched and potentially leaking across updates (e.g. when switching to .color). Consider explicitly resetting any stateful content-related properties before returning so the layer can’t carry stale configuration forward.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.


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 {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When headroom drops back to .standard, wantsExtendedDynamicRangeContent is never set back to false, so a layer that previously displayed HDR content may remain in EDR mode. That can make subsequent SDR (or background-color-only) updates behave differently than intended.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

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
46 changes: 44 additions & 2 deletions Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CALayerPrivate.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,59 @@

#import <QuartzCore/CALayer.h>

/// 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);
@property (nonatomic, assign, readonly) BOOL hasBeenCommitted_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(hasBeenCommitted);
@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

Expand Down
9 changes: 9 additions & 0 deletions Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CALayerPrivate.m
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
#import <objc/runtime.h>

@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);
Expand Down Expand Up @@ -46,4 +51,8 @@ - (void)setOpenSwiftUI_viewTestProperties:(uint64_t)properties {
}
@end

void _CALayerSetSplatsContentsAlpha(CALayer * _Nonnull layer, BOOL splatAlpha) {
layer.contentsSwizzle = splatAlpha ? kCALayerContentsSwizzleAAAA : kCALayerContentsSwizzleRGBA;
}

#endif /* <QuartzCore/CALayer.h> */
Loading
Loading