diff --git a/Package.resolved b/Package.resolved index 6854d8817..af8c37e7b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/OpenSwiftUIProject/DarwinPrivateFrameworks.git", "state" : { "branch" : "main", - "revision" : "18d26f5dcc334468fad0904697233faeb41a1805" + "revision" : "f97c5e4c96d3bf66b5161a0635638360b386c6e8" } }, { @@ -16,7 +16,7 @@ "location" : "https://github.com/OpenSwiftUIProject/OpenAttributeGraph", "state" : { "branch" : "main", - "revision" : "2682d4b34acd8a1482dd62e66e084f346d6ca310" + "revision" : "91dd6f1b3b3bcfc13872b0e7eb145302279ebf04" } }, { @@ -49,10 +49,10 @@ { "identity" : "swift-numerics", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", + "location" : "https://github.com/apple/swift-numerics", "state" : { - "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", - "version" : "1.0.3" + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" } }, { diff --git a/Sources/OpenSwiftUICore/Data/EnvironmentKeys/EnvironmentAdditions.swift b/Sources/OpenSwiftUICore/Data/EnvironmentKeys/EnvironmentAdditions.swift index a9ec24110..219e8ebc5 100644 --- a/Sources/OpenSwiftUICore/Data/EnvironmentKeys/EnvironmentAdditions.swift +++ b/Sources/OpenSwiftUICore/Data/EnvironmentKeys/EnvironmentAdditions.swift @@ -59,21 +59,13 @@ extension Image { /// A scale that produces large images. case large - @_spi(ForOpenSwiftUIOnly) - @available(OpenSwiftUI_v6_0, *) - case _fittingCircleRadius(_fixedPointFraction: UInt16) - @_spi(Private) @available(OpenSwiftUI_v6_0, *) - @available(*, deprecated, renamed: "_controlCenter_large") - @_alwaysEmitIntoClient - public static func fittingCircleRadius(pointSizeMultiple: CGFloat) -> Image.Scale { - ._controlCenter_large - } + case _controlCenter_small, _controlCenter_medium, _controlCenter_large @_spi(Private) @available(OpenSwiftUI_v6_0, *) - case _controlCenter_small, _controlCenter_medium, _controlCenter_large + case _watch_toolbar_medium } } diff --git a/Sources/OpenSwiftUICore/Data/Protobuf/ProtobufEncoder.swift b/Sources/OpenSwiftUICore/Data/Protobuf/ProtobufEncoder.swift index dda2ce72a..f0f0fc340 100644 --- a/Sources/OpenSwiftUICore/Data/Protobuf/ProtobufEncoder.swift +++ b/Sources/OpenSwiftUICore/Data/Protobuf/ProtobufEncoder.swift @@ -382,6 +382,20 @@ extension ProtobufEncoder { try encodeMessage(value) } + /// Encodes an attached value with a hashable key and a data-producing closure. + /// + /// When an `ArchiveWriter` is available, the data is deduplicated using + /// a SHA1 hash and stored as an attachment reference. Otherwise, the data + /// is encoded inline at field 2. + /// + /// - Parameters: + /// - key: A hashable key used for attachment deduplication. + /// - data: A closure that produces the data to encode. + package mutating func encodeAttachedValue(key: Key, data: () throws -> Data) throws { + // TODO: ArchiveWriter support for attachment deduplication + try dataField(2, data()) + } + /// Encodes a string field. /// /// - Parameters: diff --git a/Sources/OpenSwiftUICore/Graphic/Color/NamedColor.swift b/Sources/OpenSwiftUICore/Graphic/Color/NamedColor.swift index 421e91ed9..44dd4d27f 100644 --- a/Sources/OpenSwiftUICore/Graphic/Color/NamedColor.swift +++ b/Sources/OpenSwiftUICore/Graphic/Color/NamedColor.swift @@ -90,7 +90,7 @@ extension Color { return nil } let gamut = key.displayGamut.cuiDisplayGamut - let idiom = CUIDeviceIdiom(rawValue: environment.cuiAssetIdiom) ?? .universal + let idiom = CUIDeviceIdiom(rawValue: environment.cuiAssetIdiom)! let color = catalog.findAsset( key: key.catalogKey, matchTypes: environment.cuiAssetMatchTypes, diff --git a/Sources/OpenSwiftUICore/Util/SFSymbolsShims.swift b/Sources/OpenSwiftUICore/Util/SFSymbolsShims.swift new file mode 100644 index 000000000..c6e5f27a2 --- /dev/null +++ b/Sources/OpenSwiftUICore/Util/SFSymbolsShims.swift @@ -0,0 +1,77 @@ +// +// SFSymbolsShims.swift +// OpenSwiftUICore +// +// Status: WIP + +// MARK: - SFSymbols Framework Access +// +// Currently uses dlopen/dlsym for dynamic symbol resolution at +// runtime. This avoids a hard link dependency on the private SFSymbols +// framework. +// +// TODO: Migrate to add SFSymbols in DarwinPrivateFrameworks package and link it with a new +// OPENSWIFTUI_LINK_SFSYMBOLS build flag (following the CoreUI pattern). +// When that migration happens: +// 1. Add `import SFSymbols` under `#if OPENSWIFTUI_LINK_SFSYMBOLS`. +// 2. Replace the dlopen-based implementations with direct calls. +// 3. Call sites using `SFSymbols.symbol_order` etc. remain unchanged +// because Swift resolves `SFSymbols.x` identically whether `SFSymbols` +// is a local enum or a qualified module name. + +#if canImport(Darwin) +import Foundation + +/// Shim for the private SFSymbols framework. +/// +/// Property names intentionally use snake_case to match the framework's +/// original API surface, ensuring a seamless migration to direct linking +/// (Option C) with no source-breaking changes at call sites. +package enum SFSymbols { + // MARK: - Module-level Properties + + /// All system symbol names in their canonical order. + package static var symbol_order: [String] { + _lookup("$s9SFSymbols12symbol_orderSaySSGvg", as: Getter_ArrayString.self)?() ?? [] + } + + /// Private system symbol names in their canonical order. + package static var private_symbol_order: [String] { + _lookup("$s9SFSymbols20private_symbol_orderSaySSGvg", as: Getter_ArrayString.self)?() ?? [] + } + + /// Mapping of alias names to their canonical symbol names. + package static var name_aliases: [String: String] { + _lookup("$s9SFSymbols12name_aliasesSDyS2SGvg", as: Getter_DictStringString.self)?() ?? [:] + } + + /// Mapping of private alias names to their canonical symbol names. + package static var private_name_aliases: [String: String] { + _lookup("$s9SFSymbols20private_name_aliasesSDyS2SGvg", as: Getter_DictStringString.self)?() ?? [:] + } + + /// Mapping from nofill symbol names to their fill variants. + package static var nofill_to_fill: [String: String] { + _lookup("$s9SFSymbols14nofill_to_fillSDyS2SGvg", as: Getter_DictStringString.self)?() ?? [:] + } + + /// Mapping from private nofill symbol names to their fill variants. + package static var private_nofill_to_fill: [String: String] { + _lookup("$s9SFSymbols22private_nofill_to_fillSDyS2SGvg", as: Getter_DictStringString.self)?() ?? [:] + } + + // MARK: - Private + + private typealias Getter_ArrayString = @convention(thin) () -> [String] + private typealias Getter_DictStringString = @convention(thin) () -> [String: String] + + private static let handle: UnsafeMutableRawPointer? = { + dlopen("/System/Library/PrivateFrameworks/SFSymbols.framework/SFSymbols", RTLD_LAZY) + }() + + private static func _lookup(_ name: String, as type: T.Type) -> T? { + guard let handle, let sym = dlsym(handle, name) else { return nil } + return unsafeBitCast(sym, to: type) + } +} +#endif diff --git a/Sources/OpenSwiftUICore/View/Archive/ArchivedView.swift b/Sources/OpenSwiftUICore/View/Archive/ArchivedView.swift new file mode 100644 index 000000000..6f26908e6 --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Archive/ArchivedView.swift @@ -0,0 +1,35 @@ +// +// ArchivedView.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +public import Foundation + +// MARK: - ArchivedViewDelegate + +@_spi(Private) +@available(OpenSwiftUI_v4_0, *) +public protocol ArchivedViewDelegate { + mutating func resolveImage(uuid: UUID) throws -> Image.ResolvedUUID +} + +// MARK: - AnyArchivedViewDelegate + +@_spi(ForOpenSwiftUIOnly) +@available(OpenSwiftUI_v6_0, *) +open class AnyArchivedViewDelegate { + package init() { + _openSwiftUIEmptyStub() + } + + @_spi(ForSwiftUIOnly) + open func resolveImage(uuid: UUID) throws -> Image.ResolvedUUID { + _openSwiftUIBaseClassAbstractMethod() + } +} + +@_spi(ForOpenSwiftUIOnly) +@available(*, unavailable) +extension AnyArchivedViewDelegate: @unchecked Sendable {} diff --git a/Sources/OpenSwiftUICore/View/Image/GraphicsImage.swift b/Sources/OpenSwiftUICore/View/Image/GraphicsImage.swift index e92c97bd8..3febbcc7d 100644 --- a/Sources/OpenSwiftUICore/View/Image/GraphicsImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/GraphicsImage.swift @@ -11,6 +11,10 @@ package import OpenCoreGraphicsShims import CoreGraphics_Private #endif +#if OPENSWIFTUI_LINK_COREUI +package import CoreUI +#endif + // MARK: - GraphicsImage package struct GraphicsImage: Equatable, Sendable { @@ -165,6 +169,20 @@ package struct ResolvedVectorGlyph: Equatable { package var animatorVersion: UInt32 package var allowsContentTransitions: Bool package var preservesVectorRepresentation: Bool + #if OPENSWIFTUI_LINK_COREUI + package let catalog: CUICatalog + #endif + + package init( + glyph: CUINamedVectorGlyph, + value: Float?, + flipsRightToLeft: Bool, + in context: ImageResolutionContext, + at location: Image.Location, + catalog: CUICatalog + ) { + _openSwiftUIUnimplementedFailure() + } package var flipsRightToLeft: Bool { animator.flipsRightToLeft diff --git a/Sources/OpenSwiftUICore/View/Image/Image.swift b/Sources/OpenSwiftUICore/View/Image/Image.swift index 28e4f3d54..a788af595 100644 --- a/Sources/OpenSwiftUICore/View/Image/Image.swift +++ b/Sources/OpenSwiftUICore/View/Image/Image.swift @@ -128,6 +128,8 @@ package struct ImageResolutionContext { self.transaction = transaction } + // TODO: willUpdateVectorGlyph + package func effectiveAllowedDynamicRange(for image: GraphicsImage) -> Image.DynamicRange? { #if canImport(CoreGraphics) guard allowedDynamicRange != .none else { diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index eefd1fd0a..68eb97b4a 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -3,20 +3,625 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: Empty +// Status: WIP // ID: 8E7DCD4CEB1ACDE07B249BFF4CBC75C0 (SwiftUICore) -package import Foundation +public import Foundation +package import OpenCoreGraphicsShims +#if canImport(CoreGraphics) +import CoreGraphics_Private +#endif +#if OPENSWIFTUI_LINK_COREUI +package import CoreUI +import GraphicsServices_Private +#endif + +// MARK: - NamedImage -// TODO package enum NamedImage { + + // MARK: - NamedImage.VectorKey + + package struct VectorKey: Hashable { + package var catalogKey: CatalogKey + + package var name: String + + package var scale: CGFloat + + package var layoutDirection: LayoutDirection + + package var locale: Locale + + package var weight: Font.Weight + + package var imageScale: Image.HashableScale + + package var pointSize: CGFloat + + package var location: Image.Location + + package var idiom: Int + + package init( + name: String, + location: Image.Location, + in env: EnvironmentValues, + textStyle: Text.Style?, + idiom: Int + ) { + self.catalogKey = CatalogKey(env) + self.name = name + self.scale = env.displayScale + self.layoutDirection = env.layoutDirection + self.locale = env.locale + let traits: Font.ResolvedTraits + if let textStyle { + traits = textStyle.fontTraits(in: env) + } else { + traits = env.effectiveSymbolFont.resolveTraits(in: env) + } + var resolvedWeight = Font.Weight(value: traits.weight) + if let legibilityWeight = env.legibilityWeight, legibilityWeight == .bold { + #if canImport(CoreText) + // Adjust for accessibility bold weight if legibility weight is .bold + resolvedWeight = Font.Weight(value: CGFloat(CTFontGetAccessibilityBoldWeightOfWeight(CGFloat(resolvedWeight.value)))) + #endif + } + self.weight = resolvedWeight + self.imageScale = Image.HashableScale(env.imageScale) + self.pointSize = traits.pointSize + self.location = location + self.idiom = idiom + } + + #if OPENSWIFTUI_LINK_COREUI + fileprivate func loadVectorInfo(from catalog: CUICatalog, idiom: Int) -> VectorInfo? { + let matchType: CatalogAssetMatchType = .cuiIdiom(idiom) + let glyph: CUINamedVectorGlyph? = catalog.findAsset( + key: catalogKey, + matchTypes: CollectionOfOne(matchType) + ) { appearanceName -> CUINamedVectorGlyph? in + let cuiIdiom = CUIDeviceIdiom(rawValue: idiom)! + let cuiLayoutDir = self.layoutDirection.cuiLayoutDirection + let glyphSz = self.imageScale.glyphSize + let glyphWt = self.weight.glyphWeight + guard var result = catalog.namedVectorGlyph( + withName: self.name, + scaleFactor: self.scale, + deviceIdiom: cuiIdiom, + layoutDirection: cuiLayoutDir, + glyphSize: glyphSz, + glyphWeight: glyphWt, + glyphPointSize: self.pointSize, + appearanceName: appearanceName, + locale: self.locale + ) else { return nil } + + let sizeScale = self.symbolSizeScale(for: result) + if sizeScale != 1.0 { + let continuousWt = self.weight.glyphContinuousWeight + if let rescaled = catalog.namedVectorGlyph( + withName: self.name, + scaleFactor: self.scale, + deviceIdiom: cuiIdiom, + layoutDirection: cuiLayoutDir, + glyphContinuousSize: sizeScale, + glyphContinuousWeight: continuousWt, + glyphPointSize: self.pointSize, + appearanceName: appearanceName, + locale: self.locale + ) { + result = rescaled + } + } + + return result + } + + guard let glyph else { return nil } + let flipsRightToLeft: Bool + if glyph.isFlippable, glyph.layoutDirection != .unspecified { + let expectedDirection: CUILayoutDirection = layoutDirection == .leftToRight ? .LTR : .RTL + flipsRightToLeft = glyph.layoutDirection != expectedDirection + } else { + flipsRightToLeft = false + } + + let metrics = Image.LayoutMetrics(glyph: glyph, flipsRightToLeft: flipsRightToLeft) + return VectorInfo( + glyph: glyph, + flipsRightToLeft: flipsRightToLeft, + layoutMetrics: metrics, + catalog: catalog + ) + } + + /// Computes a scale factor for symbol images based on the glyph's + /// actual path radius relative to a reference circle.fill size. + /// + /// The algorithm: + /// 1. Gets the allowed scale range for this image scale + /// 2. Iterates monochrome glyph layers, skipping slash/badge overlays + /// 3. Computes the maximum radius of all path points from the metric center + /// 4. Computes a reference circle.fill diameter for the current weight/pointSize + /// 5. Returns the ratio clamped to the allowed range + private func symbolSizeScale(for glyph: CUINamedVectorGlyph) -> CGFloat { + let range = imageScale.allowedScaleRange + guard range.lowerBound < range.upperBound else { + return range.lowerBound + } + + guard let layers = glyph.monochromeLayers else { + return 1.0 + } + + let center = glyph.metricCenter + let inverseScale = Float(1.0 / glyph.scale) + let centerF = SIMD2(Float(center.x), Float(center.y)) + + var maxRadiusSq: Float = 0.0 + + for layer in layers { + guard layer.opacity > 0 else { continue } + + if let tags = layer.tags { + if tags.contains("_slash") || tags.contains("_badge") { + continue + } + } + + guard let path = layer.shape else { continue } + + // TODO: RBPathApplyLines + // Iterate path points, computing max squared distance from center. + // The original uses RBPathApplyLines to flatten curves; here we + // process all element endpoints which is equivalent for line-based + // glyph outlines and conservative for curves. + path.applyWithBlock { elementPointer in + let element = elementPointer.pointee + let points = element.points + let pointCount: Int + switch element.type { + case .moveToPoint: pointCount = 1 + case .addLineToPoint: pointCount = 1 + case .addQuadCurveToPoint: pointCount = 2 + case .addCurveToPoint: pointCount = 3 + case .closeSubpath: pointCount = 0 + @unknown default: pointCount = 0 + } + for i in 0 ..< pointCount { + let p = points[i] + let scaled = SIMD2(Float(p.x), Float(p.y)) * inverseScale + let delta = scaled - centerF + let distSq = delta.x * delta.x + delta.y * delta.y + if distSq > maxRadiusSq { + maxRadiusSq = distSq + } + } + } + } + + let diameter = imageScale.circleDotFillSize(pointSize: pointSize, weight: weight) + let referenceRadius = imageScale.maxRadius(diameter: diameter) + let actualRadius = CGFloat(maxRadiusSq.squareRoot()) + + guard actualRadius > 0 else { + return 1.0 + } + + let ratio = referenceRadius / actualRadius + return min(range.upperBound, max(range.lowerBound, ratio)) + } + #endif + } + + + // MARK: - VectorInfo + + fileprivate struct VectorInfo { + #if OPENSWIFTUI_LINK_COREUI + var glyph: CUINamedVectorGlyph + + var flipsRightToLeft: Bool + + var layoutMetrics: Image.LayoutMetrics + + weak var catalog: CUICatalog? + #endif + } + + + // MARK: - NamedImage.BitmapKey [TBA] + + package struct BitmapKey: Hashable { + package var catalogKey: CatalogKey + + package var name: String + + package var scale: CGFloat + + package var location: Image.Location + + package var layoutDirection: LayoutDirection + + package var locale: Locale + + package var gamut: DisplayGamut + + package var idiom: Int + + package var subtype: Int + + package var horizontalSizeClass: Int8 + + package var verticalSizeClass: Int8 + + package init( + name: String, + location: Image.Location, + in env: EnvironmentValues + ) { + self.catalogKey = CatalogKey(env) + self.name = name + self.scale = env.displayScale + self.location = location + self.layoutDirection = env.layoutDirection + self.locale = env.locale + self.gamut = env.displayGamut + self.idiom = env.cuiAssetIdiom + self.subtype = env.cuiAssetSubtype + self.horizontalSizeClass = Self.convertSizeClass(env.horizontalSizeClass) + self.verticalSizeClass = Self.convertSizeClass(env.verticalSizeClass) + } + + package init( + catalogKey: CatalogKey, + name: String, + scale: CGFloat, + location: Image.Location, + layoutDirection: LayoutDirection, + locale: Locale, + gamut: DisplayGamut, + idiom: Int, + subtype: Int, + horizontalSizeClass: Int8 = 0, + verticalSizeClass: Int8 = 0 + ) { + self.catalogKey = catalogKey + self.name = name + self.scale = scale + self.location = location + self.layoutDirection = layoutDirection + self.locale = locale + self.gamut = gamut + self.idiom = idiom + self.subtype = subtype + self.horizontalSizeClass = horizontalSizeClass + self.verticalSizeClass = verticalSizeClass + } + + // [TBA] + // Converts UserInterfaceSizeClass? to Int8: + // nil -> 0, .compact -> 1, .regular -> 2 + private static func convertSizeClass(_ sizeClass: UserInterfaceSizeClass?) -> Int8 { + guard let sizeClass else { + return 0 + } + switch sizeClass { + case .compact: return 1 + case .regular: return 2 + } + } + + #if OPENSWIFTUI_LINK_COREUI + func loadBitmapInfo(location: Image.Location, idiom: Int, subtype: Int) -> BitmapInfo? { + // Resolve catalog from location + guard case .bundle(let bundle) = location, + let (catalog, _) = NamedImage.sharedCache[bundle] else { + // TODO: .system / .privateSystem asset manager support + return nil + } + + // Build match types based on idiom + let matchTypes = CatalogAssetMatchType.defaultValue(idiom: idiom) + + let selfCUIDirection: CUILayoutDirection = layoutDirection == .leftToRight ? .LTR : .RTL + + // Find asset via appearance-matching lookup + let namedImage: CUINamedImage? = catalog.findAsset( + key: catalogKey, + matchTypes: matchTypes + ) { appearanceName -> CUINamedImage? in + catalog.image( + withName: self.name, + scaleFactor: self.scale, + deviceIdiom: CUIDeviceIdiom(rawValue: idiom)!, + deviceSubtype: CUISubtype(rawValue: UInt(subtype)) ?? .normal, + displayGamut: CUIDisplayGamut(rawValue: UInt(self.gamut.rawValue)) ?? .SRGB, + layoutDirection: selfCUIDirection, + sizeClassHorizontal: CUIUserInterfaceSizeClass(rawValue: Int(self.horizontalSizeClass)) ?? .any, + sizeClassVertical: CUIUserInterfaceSizeClass(rawValue: Int(self.verticalSizeClass)) ?? .any, + appearanceName: appearanceName, + locale: self.locale.identifier + ) + } + + guard let namedImage else { return nil } + + // Extract image contents + let contents: GraphicsImage.Contents + let unrotatedPixelSize: CGSize + + // TODO: Vector image path (Semantics v3 + preservedVectorRepresentation + VectorImageLayer) + // When linked on or after v3, if namedImage.preservedVectorRepresentation is true, + // attempt to get a CUINamedVectorImage from the catalog and wrap it in a + // VectorImageLayer. Falls through to CGImage path on failure. + + // CGImage path + guard let cgImage = namedImage.image else { return nil } + + // Prevent weakly-cached catalog from being deallocated while CGImage exists + if let (cat, retain) = NamedImage.sharedCache[bundle], retain { + CGImageSetProperty( + cgImage, + "com.apple.SwiftUI.ObjectToRetain" as CFString, + Unmanaged.passUnretained(cat).toOpaque() + ) + } + + contents = .cgImage(cgImage) + unrotatedPixelSize = CGSize( + width: CGFloat(cgImage.width), + height: CGFloat(cgImage.height) + ) + + // Template rendering mode + let renderingMode: Image.TemplateRenderingMode? + switch namedImage.templateRenderingMode { + case .original: + renderingMode = .original + case .template: + renderingMode = .template + default: + renderingMode = nil + } + + // Orientation from EXIF value + var orientation = Image.Orientation(exifValue: Int(namedImage.exifOrientation) & 0xF) ?? .up + + // RTL flipping: if image is flippable and its direction doesn't match + // the requested direction, flip by XOR-ing the orientation raw value + let cuiDirection = namedImage.layoutDirection + if namedImage.isFlippable, cuiDirection != .unspecified, cuiDirection != selfCUIDirection { + orientation = Image.Orientation(rawValue: orientation.rawValue ^ 1)! + } + + // Scale + let imageScale = namedImage.scale + + // Resizing info from 9-slice data + let resizingInfo: Image.ResizingInfo? + if namedImage.hasSliceInformation { + let insets = namedImage.edgeInsets + let edgeInsets = EdgeInsets( + top: insets.top, + leading: insets.left, + bottom: insets.bottom, + trailing: insets.right + ) + let mode: Image.ResizingMode = namedImage.resizingMode == .tiles ? .tile : .stretch + resizingInfo = Image.ResizingInfo(capInsets: edgeInsets, mode: mode) + } else { + resizingInfo = nil + } + + return BitmapInfo( + contents: contents, + scale: imageScale, + orientation: orientation, + unrotatedPixelSize: unrotatedPixelSize, + renderingMode: renderingMode, + resizingInfo: resizingInfo + ) + } + #endif + } + + // MARK: - NamedImage.BitmapInfo + + package struct BitmapInfo { + package var contents: GraphicsImage.Contents + + package var scale: CGFloat + + package var orientation: Image.Orientation + + package var unrotatedPixelSize: CGSize + + package var renderingMode: Image.TemplateRenderingMode? + + package var resizingInfo: Image.ResizingInfo? + + package init( + contents: GraphicsImage.Contents, + scale: CGFloat, + orientation: Image.Orientation, + unrotatedPixelSize: CGSize, + renderingMode: Image.TemplateRenderingMode?, + resizingInfo: Image.ResizingInfo? + ) { + self.contents = contents + self.scale = scale + self.orientation = orientation + self.unrotatedPixelSize = unrotatedPixelSize + self.renderingMode = renderingMode + self.resizingInfo = resizingInfo + } + } + + // MARK: - NamedImage.DecodedInfo + + package struct DecodedInfo { + package var contents: GraphicsImage.Contents + + package var scale: CGFloat + + package var unrotatedPixelSize: CGSize + + package var orientation: Image.Orientation + } + + // MARK: - NamedImage.Key + package enum Key: Equatable { + case bitmap(BitmapKey) case uuid(UUID) } + + // MARK: - NamedImage.Errors + + package enum Errors: Error, Equatable, Hashable { + case missingCatalogImage + case missingUUIDImage + } + + // MARK: - NamedImage.Cache [TBA] + + package struct Cache { + private struct ImageCacheData { + var vectors: [NamedImage.VectorKey: NamedImage.VectorInfo] = [:] + var bitmaps: [NamedImage.BitmapKey: NamedImage.BitmapInfo] = [:] + var uuids: [UUID: NamedImage.DecodedInfo] = [:] + var catalogs: [URL: WeakCatalog] = [:] + } + + private struct WeakCatalog { + #if OPENSWIFTUI_LINK_COREUI + weak var catalog: CUICatalog? + #endif + } + + package var archiveDelegate: AnyArchivedViewDelegate? + + @AtomicBox + private var data: ImageCacheData = .init() + + package init(archiveDelegate: AnyArchivedViewDelegate? = nil) { + self.archiveDelegate = archiveDelegate + } + + // MARK: Cache subscripts + + #if OPENSWIFTUI_LINK_COREUI + // Looks up cached VectorInfo for key; if not found or catalog changed, + // calls loadVectorInfo and caches the result. + fileprivate subscript(key: VectorKey, catalog: CUICatalog) -> VectorInfo? { + let cached = data.vectors[key] + if let cached { + if let cachedCatalog = cached.catalog, cachedCatalog == catalog { + return cached + } + } + guard let info = key.loadVectorInfo(from: catalog, idiom: key.idiom) else { + return nil + } + data.vectors[key] = info + return info + } + #endif + + // Looks up cached BitmapInfo for key; if not found, + // calls loadBitmapInfo and caches the result. + package subscript(key: BitmapKey, location: Image.Location) -> BitmapInfo? { + #if OPENSWIFTUI_LINK_COREUI + if let cached = data.bitmaps[key] { + return cached + } + guard let info = key.loadBitmapInfo(location: location, idiom: key.idiom, subtype: key.subtype) else { + return nil + } + data.bitmaps[key] = info + return info + #else + _openSwiftUIPlatformUnimplementedWarning() + return nil + #endif + } + + #if OPENSWIFTUI_LINK_COREUI + // Resolves a CUICatalog for the given bundle. + // First tries defaultUICatalog; falls back to Assets.car with weak caching. + package subscript(bundle: Bundle) -> (CUICatalog, retain: Bool)? { + if let catalog = CUICatalog.defaultUICatalog(for: bundle) { + return (catalog, retain: false) + } + guard let url = bundle.url(forResource: "Assets", withExtension: "car") else { + return nil + } + if let weakCatalog = data.catalogs[url], let catalog = weakCatalog.catalog { + return (catalog, retain: true) + } + // Clean up stale entries where weak ref is nil + data.catalogs = data.catalogs.filter { $0.value.catalog != nil } + guard let catalog = try? CUICatalog(url: url) else { + return nil + } + data.catalogs[url] = WeakCatalog(catalog: catalog) + return (catalog, retain: true) + } + + #endif + + package func decode(_ key: Key) throws -> DecodedInfo { + switch key { + case .bitmap(let bitmapKey): + guard let info = self[bitmapKey, bitmapKey.location] else { + throw Errors.missingCatalogImage + } + return DecodedInfo( + contents: info.contents, + scale: info.scale, + unrotatedPixelSize: info.unrotatedPixelSize, + orientation: info.orientation + ) + case .uuid(let uuid): + if let cached = data.uuids[uuid] { + return cached + } + guard let delegate = archiveDelegate else { + throw Errors.missingUUIDImage + } + let resolved = try delegate.resolveImage(uuid: uuid) + let cgImage = resolved.cgImage + let width = CGFloat(cgImage.width) + let height = CGFloat(cgImage.height) + let decoded = DecodedInfo( + contents: .cgImage(cgImage), + scale: resolved.scale, + unrotatedPixelSize: CGSize(width: width, height: height), + orientation: resolved.orientation + ) + data.uuids[uuid] = decoded + return decoded + } + } + } + + // MARK: - NamedImage.sharedCache + + package static var sharedCache = Cache() +} + +@available(OpenSwiftUI_v1_0, *) +extension Image { + public static var _mainNamedBundle: Bundle? { nil } } +@available(OpenSwiftUI_v1_0, *) extension Image { - // TODO + // MARK: - Image.Location [TBA] + package enum Location: Equatable, Hashable { case bundle(Bundle) case system @@ -29,7 +634,18 @@ extension Image { return true } - // package var catalog: CUICatalog? + #if OPENSWIFTUI_LINK_COREUI + package var catalog: CUICatalog? { + switch self { + case .system: + return Self.systemAssetManager.catalog + case .privateSystem: + return Self.privateSystemAssetManager.catalog + case .bundle(let bundle): + return NamedImage.sharedCache[bundle]?.0 + } + } + #endif package var bundle: Bundle? { guard case .bundle(let bundle) = self else { @@ -37,5 +653,1071 @@ extension Image { } return bundle } + + package func fillVariant(_ variants: SymbolVariants, name: String) -> String? { + guard variants.contains(.fill) else { return nil } + switch self { + case .system: + return Self.systemAssetManager.fillMapping[name] + case .privateSystem: + return Self.privateSystemAssetManager.fillMapping[name] + case .bundle: + return name + ".fill" + } + } + + package func mayContainSymbol(_ name: String) -> Bool { + switch self { + case .system: + return Self.systemAssetManager.symbols.contains(name) + case .privateSystem: + return Self.privateSystemAssetManager.symbols.contains(name) + case .bundle: + return true + } + } + + private func aliasedName(_ name: String) -> String { + switch self { + case .system: + return Self.systemAssetManager.nameAliases[name] ?? name + case .privateSystem: + return Self.privateSystemAssetManager.nameAliases[name] ?? name + case .bundle: + return name + } + } + + package func findShapeAndFillVariantName(_ variants: SymbolVariants, base: String, body: (String) -> T?) -> T? { + if let shapeName = variants.shapeVariantName(name: base) { + let aliasedShape = aliasedName(shapeName) + if let fillName = fillVariant(variants, name: aliasedShape) { + if let result = body(fillName) { return result } + } + if let result = body(aliasedShape) { return result } + } + let aliasedBase = aliasedName(base) + if let fillName = fillVariant(variants, name: aliasedBase) { + if let result = body(fillName) { return result } + } + return body(aliasedBase) + } + + package func findName(_ variants: SymbolVariants, base: String, body: (String) -> T?) -> T? { + let normalizedVariants = variants._normalizedForNameLookup() + if normalizedVariants.contains(.slash) { + let slashName = base + ".slash" + if let result = findShapeAndFillVariantName(normalizedVariants, base: slashName, body: body) { + return result + } + return findShapeAndFillVariantName(normalizedVariants, base: base, body: body) + } else { + return findShapeAndFillVariantName(normalizedVariants, base: base, body: body) + } + } + + package static let systemAssetManager = SystemAssetManager(internalUse: false) + + package static let privateSystemAssetManager = SystemAssetManager(internalUse: true) + + package struct SystemAssetManager { + #if OPENSWIFTUI_LINK_COREUI + let catalog: CUICatalog + #endif + let fillMapping: [String: String] + let nameAliases: [String: String] + let symbols: [String] + + package init(internalUse: Bool) { + let bundlePath: String + if internalUse { + fillMapping = SFSymbols.private_nofill_to_fill + nameAliases = SFSymbols.private_name_aliases + symbols = SFSymbols.private_symbol_order + bundlePath = "/System/Library/CoreServices/CoreGlyphsPrivate.bundle" + } else { + fillMapping = SFSymbols.nofill_to_fill + nameAliases = SFSymbols.name_aliases + symbols = SFSymbols.symbol_order + bundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle" + } + #if OPENSWIFTUI_LINK_COREUI + let fullPath = _SimulatorSystemRootDirectory() + bundlePath + let bundle = Bundle(path: fullPath)! + catalog = try! CUICatalog(name: "Assets", from: bundle, error: ()) + #endif + } + } + } + + // MARK: - Image named initializers + + /// Creates a labeled image that you can use as content for controls. + /// + /// - Parameters: + /// - name: The name of the image resource to lookup, as well as the + /// localization key with which to label the image. + /// - bundle: The bundle to search for the image resource and localization + /// content. If `nil`, OpenSwiftUI uses the main `Bundle`. Defaults to `nil`. + public init(_ name: String, bundle: Bundle? = nil) { + self.init( + NamedImageProvider( + name: name, + location: .bundle(bundle ?? Bundle.main), + label: AccessibilityImageLabel(Text(LocalizedStringKey(name), bundle: bundle)), + decorative: false + ) + ) + } + + /// Creates a labeled image that you can use as content for controls, with + /// the specified label. + /// + /// - Parameters: + /// - name: The name of the image resource to lookup + /// - bundle: The bundle to search for the image resource. If `nil`, + /// OpenSwiftUI uses the main `Bundle`. Defaults to `nil`. + /// - label: The label associated with the image. OpenSwiftUI uses the label + /// for accessibility. + public init(_ name: String, bundle: Bundle? = nil, label: Text) { + self.init( + NamedImageProvider( + name: name, + location: .bundle(bundle ?? Bundle.main), + label: AccessibilityImageLabel(label), + decorative: false + ) + ) + } + + /// Creates an unlabeled, decorative image. + /// + /// OpenSwiftUI ignores this image for accessibility purposes. + /// + /// - Parameters: + /// - name: The name of the image resource to lookup + /// - bundle: The bundle to search for the image resource. If `nil`, + /// OpenSwiftUI uses the main `Bundle`. Defaults to `nil`. + public init(decorative name: String, bundle: Bundle? = nil) { + self.init( + NamedImageProvider( + name: name, + location: .bundle(bundle ?? Bundle.main), + label: nil, + decorative: true + ) + ) + } + + /// Creates a system symbol image. + /// + /// This initializer creates an image using a system-provided symbol. Use + /// [SF Symbols](https://developer.apple.com/design/resources/#sf-symbols) + /// to find symbols and their corresponding names. + /// + /// To create a custom symbol image from your app's asset catalog, use + /// ``Image/init(_:bundle:)`` instead. + /// + /// - Parameters: + /// - systemName: The name of the system symbol image. + /// Use the SF Symbols app to look up the names of system symbol images. + @available(macOS, introduced: 11.0) + public init(systemName: String) { + self.init( + NamedImageProvider( + name: systemName, + location: .system, + label: .systemSymbol(systemName), + decorative: false + ) + ) + } + + /// SPI for internal clients to access internal system symbols. + @available(OpenSwiftUI_v1_0, *) + public init(_internalSystemName systemName: String) { + self.init( + NamedImageProvider( + name: systemName, + location: .privateSystem, + label: .systemSymbol(systemName), + decorative: false, + backupLocation: .system + ) + ) + } + + // MARK: - Image.NamedImageProvider + + package struct NamedImageProvider: ImageProvider { + package var name: String + + package var value: Float? + + package var location: Image.Location + + package var backupLocation: Image.Location? + + package var label: AccessibilityImageLabel? + + package var decorative: Bool + + package init( + name: String, + value: Float? = nil, + location: Image.Location, + label: AccessibilityImageLabel?, + decorative: Bool, + backupLocation: Image.Location? = nil + ) { + self.name = name + self.value = value + self.location = location + self.label = label + self.decorative = decorative + self.backupLocation = backupLocation + } + + package func resolve(in context: ImageResolutionContext) -> Image.Resolved { + #if OPENSWIFTUI_LINK_COREUI + guard let catalog = location.catalog else { + return resolveError(in: context.environment) + } + if let info = vectorInfo(in: context, from: catalog, at: location) { + return resolveVector(info: info, value: value, in: context, at: location, catalog: catalog) + } + if let backupLocation, + let backupCatalog = backupLocation.catalog, + let info = vectorInfo(in: context, from: backupCatalog, at: backupLocation) { + return resolveVector(info: info, value: value, in: context, at: backupLocation, catalog: backupCatalog) + } + if location.supportsNonVectorImages { + let key = NamedImage.BitmapKey( + name: name, + location: location, + in: context.environment + ) + if let info = NamedImage.sharedCache[key, key.location] { + return resolveBitmap(key: key, info: info, in: context) + } + } + #endif + return resolveError(in: context.environment) + } + + package func resolveError(in environment: EnvironmentValues) -> Image.Resolved { + if let bundle = location.bundle { + Log.externalWarning("No image named '\(name)' found in asset catalog for \(bundle.bundlePath)") + } else { + Log.externalWarning("No symbol named '\(name)' found in system symbol set") + } + return Image.Resolved( + image: GraphicsImage( + contents: nil, + scale: 1.0, + unrotatedPixelSize: .zero, + orientation: .up, + isTemplate: false + ), + decorative: decorative, + label: label + ) + } + + #if OPENSWIFTUI_LINK_COREUI + private func vectorInfo( + in context: ImageResolutionContext, + from catalog: CUICatalog, + at location: Image.Location + ) -> NamedImage.VectorInfo? { + let environment = context.environment + let variants = environment.symbolVariants + let result: NamedImage.VectorInfo? = location.findName(variants, base: name) { candidateName in + vectorInfo( + name: name, + in: context, + from: catalog, + at: location + ) + } + guard let result else { + return nil + } + guard !environment.shouldRedactSymbolImages else { + return vectorInfo( + name: "circle.fill", + in: context, + from: Image.Location.systemAssetManager.catalog, + at: location + ) + } + return result + } + + private func vectorInfo( + name: String, + in context: ImageResolutionContext, + from catalog: CUICatalog, + at location: Image.Location + ) -> NamedImage.VectorInfo? { + guard location.mayContainSymbol(name) else { + return nil + } + let environment = context.environment + let key = NamedImage.VectorKey( + name: name, + location: location, + in: environment, + textStyle: context.textStyle, + idiom: environment.cuiAssetIdiom + ) + return NamedImage.sharedCache[key, catalog] + } + + // TODO: ResolvedVectorGlyph + // [TBA] + private func resolveVector( + info: NamedImage.VectorInfo, + value: Float?, + in context: ImageResolutionContext, + at location: Image.Location, + catalog: CUICatalog + ) -> Image.Resolved { + let environment = context.environment + let glyph = info.glyph + let flipsRightToLeft = info.flipsRightToLeft + var layoutMetrics = info.layoutMetrics + + // Create or reuse symbol animator + let animator: ORBSymbolAnimator = context.symbolAnimator ?? ORBSymbolAnimator() + let resolvedVectorGlyph = ResolvedVectorGlyph( + glyph: info.glyph, + value: value, + flipsRightToLeft: info.flipsRightToLeft, + in: context, + at: location, + catalog: catalog + ) + + let scale = glyph.scale + let alignmentRect = glyph.alignmentRect + let pixelWidth = scale * alignmentRect.width + let pixelHeight = scale * alignmentRect.height + + var graphicsImage = GraphicsImage( + contents: .vectorGlyph(resolvedVectorGlyph), + scale: scale, + unrotatedPixelSize: CGSize(width: pixelWidth, height: pixelHeight), + orientation: .up, + isTemplate: false + ) + + // Handle symbol background variant + let variants = environment.symbolVariants + var backgroundShape: SymbolVariants.Shape? + var backgroundCornerRadius: CGFloat? + if variants.contains(.background) { + let shape = variants.shape ?? .circle + backgroundShape = shape + let growsToFit = environment.symbolsGrowToFitBackground + layoutMetrics.adjustForBackground( + glyph: glyph, + shape: shape, + size: &graphicsImage.unrotatedPixelSize, + growsToFitBackground: growsToFit + ) + backgroundCornerRadius = environment.symbolBackgroundCornerRadius + } + + // Handle symbol redaction + if environment.shouldRedactSymbolImages { + let color = Color.foreground.resolve(in: environment) + graphicsImage.contents = GraphicsImage.Contents.color(color.multiplyingOpacity(by: 0.16)) + } + + graphicsImage.allowedDynamicRange = context.effectiveAllowedDynamicRange(for: graphicsImage) + + var resolved = Image.Resolved( + image: graphicsImage, + decorative: decorative, + label: label, + backgroundShape: backgroundShape, + backgroundCornerRadius: backgroundCornerRadius + ) + resolved.layoutMetrics = layoutMetrics + return resolved + } + #endif + + private func bitmapInfo( + in environment: EnvironmentValues, + from location: Image.Location + ) -> NamedImage.BitmapInfo? { + let key = NamedImage.BitmapKey( + name: name, + location: location, + in: environment + ) + return NamedImage.sharedCache[key, location] + } + + private func resolveBitmap( + key: NamedImage.BitmapKey, + info: NamedImage.BitmapInfo, + in context: ImageResolutionContext + ) -> Image.Resolved { + let environment = context.environment + let isTemplate = environment.imageIsTemplate(renderingMode: info.renderingMode) + + let contents: GraphicsImage.Contents + if context.options.contains(.useCatalogReferences) { + contents = .named(.bitmap(key)) + } else { + contents = info.contents + } + + var graphicsImage = GraphicsImage( + contents: contents, + scale: info.scale, + unrotatedPixelSize: info.unrotatedPixelSize, + orientation: info.orientation, + isTemplate: isTemplate, + resizingInfo: info.resizingInfo + ) + graphicsImage.allowedDynamicRange = context.effectiveAllowedDynamicRange(for: graphicsImage) + if environment.shouldRedactContent { + graphicsImage.redact(in: environment) + } + return Image.Resolved( + image: graphicsImage, + decorative: decorative, + label: label + ) + } + + package func resolveNamedImage(in context: ImageResolutionContext) -> Image.NamedResolved? { + let resolved = resolve(in: context) + guard resolved.image.contents != nil else { + return nil + } + let environment = context.environment + let symbolRenderingMode = context.symbolRenderingMode ?? environment.symbolRenderingMode + let info = bitmapInfo(in: environment, from: location) + let isTemplate: Bool + if let info { + isTemplate = environment.imageIsTemplate(renderingMode: info.renderingMode) + } else { + isTemplate = false + } + return Image.NamedResolved( + name: name, + location: location, + value: value, + symbolRenderingMode: symbolRenderingMode?.storage, + isTemplate: isTemplate, + environment: environment + ) + } + } +} + +// MARK: - Image named initializers with variableValue + +@available(OpenSwiftUI_v4_0, *) +extension Image { + + /// Creates a system symbol image with a variable value. + /// + /// This initializer creates an image using a system-provided symbol. The + /// rendered symbol may alter its appearance to represent the value + /// provided in `variableValue`. Use + /// [SF Symbols](https://developer.apple.com/design/resources/#sf-symbols) + /// (version 4.0 or later) to find system symbols that support variable + /// values and their corresponding names. + /// + /// The following example shows the effect of creating the `"chart.bar.fill"` + /// symbol with different values. + /// + /// HStack{ + /// Image(systemName: "chart.bar.fill", variableValue: 0.3) + /// Image(systemName: "chart.bar.fill", variableValue: 0.6) + /// Image(systemName: "chart.bar.fill", variableValue: 1.0) + /// } + /// .font(.system(.largeTitle)) + /// + /// ![Three instances of the bar chart symbol, arranged horizontally. + /// The first fills one bar, the second fills two bars, and the last + /// symbol fills all three bars.](Image-3) + /// + /// To create a custom symbol image from your app's asset + /// catalog, use ``Image/init(_:variableValue:bundle:)`` instead. + /// + /// - Parameters: + /// - systemName: The name of the system symbol image. + /// Use the SF Symbols app to look up the names of system + /// symbol images. + /// - variableValue: An optional value between `0.0` and `1.0` that + /// the rendered image can use to customize its appearance, if + /// specified. If the symbol doesn't support variable values, this + /// parameter has no effect. Use the SF Symbols app to look up which + /// symbols support variable values. + public init(systemName: String, variableValue: Double?) { + self.init( + NamedImageProvider( + name: systemName, + value: variableValue.map { Float($0) }, + location: .system, + label: .systemSymbol(systemName), + decorative: false + ) + ) + } + + /// SPI for internal clients to access internal system symbols. + public init(_internalSystemName systemName: String, variableValue: Double?) { + self.init( + NamedImageProvider( + name: systemName, + value: variableValue.map { Float($0) }, + location: .privateSystem, + label: .systemSymbol(systemName), + decorative: false, + backupLocation: .system + ) + ) + } + + /// Creates a labeled image that you can use as content for controls, + /// with a variable value. + /// + /// This initializer creates an image using a using a symbol in the + /// specified bundle. The rendered symbol may alter its appearance to + /// represent the value provided in `variableValue`. + /// + /// > Note: See WWDC22 session [10158: Adopt variable color in SF + /// Symbols](https://developer.apple.com/wwdc22/10158/) for details + /// on how to create symbols that support variable values. + /// + /// - Parameters: + /// - name: The name of the image resource to lookup, as well as + /// the localization key with which to label the image. + /// - variableValue: An optional value between `0.0` and `1.0` that + /// the rendered image can use to customize its appearance, if + /// specified. If the symbol doesn't support variable values, this + /// parameter has no effect. + /// - bundle: The bundle to search for the image resource and + /// localization content. If `nil`, OpenSwiftUI uses the main + /// `Bundle`. Defaults to `nil`. + /// + public init(_ name: String, variableValue: Double?, bundle: Bundle? = nil) { + self.init( + NamedImageProvider( + name: name, + value: variableValue.map { Float($0) }, + location: .bundle(bundle ?? Bundle.main), + label: AccessibilityImageLabel(Text(LocalizedStringKey(name), bundle: bundle)), + decorative: false + ) + ) + } + + /// Creates a labeled image that you can use as content for controls, with + /// the specified label and variable value. + /// + /// This initializer creates an image using a using a symbol in the + /// specified bundle. The rendered symbol may alter its appearance to + /// represent the value provided in `variableValue`. + /// + /// > Note: See WWDC22 session [10158: Adopt variable color in SF + /// Symbols](https://developer.apple.com/wwdc22/10158/) for details on + /// how to create symbols that support variable values. + /// + /// - Parameters: + /// - name: The name of the image resource to lookup. + /// - variableValue: An optional value between `0.0` and `1.0` that + /// the rendered image can use to customize its appearance, if + /// specified. If the symbol doesn't support variable values, this + /// parameter has no effect. + /// - bundle: The bundle to search for the image resource. If + /// `nil`, OpenSwiftUI uses the main `Bundle`. Defaults to `nil`. + /// - label: The label associated with the image. OpenSwiftUI uses + /// the label for accessibility. + /// + public init(_ name: String, variableValue: Double?, bundle: Bundle? = nil, label: Text) { + self.init( + NamedImageProvider( + name: name, + value: variableValue.map { Float($0) }, + location: .bundle(bundle ?? Bundle.main), + label: AccessibilityImageLabel(label), + decorative: false + ) + ) + } + + /// Creates an unlabeled, decorative image, with a variable value. + /// + /// This initializer creates an image using a using a symbol in the + /// specified bundle. The rendered symbol may alter its appearance to + /// represent the value provided in `variableValue`. + /// + /// > Note: See WWDC22 session [10158: Adopt variable color in SF + /// Symbols](https://developer.apple.com/wwdc22/10158/) for details on + /// how to create symbols that support variable values. + /// + /// OpenSwiftUI ignores this image for accessibility purposes. + /// + /// - Parameters: + /// - name: The name of the image resource to lookup. + /// - variableValue: An optional value between `0.0` and `1.0` that + /// the rendered image can use to customize its appearance, if + /// specified. If the symbol doesn't support variable values, this + /// parameter has no effect. + /// - bundle: The bundle to search for the image resource. If + /// `nil`, OpenSwiftUI uses the main `Bundle`. Defaults to `nil`. + /// + public init(decorative name: String, variableValue: Double?, bundle: Bundle? = nil) { + self.init( + NamedImageProvider( + name: name, + value: variableValue.map { Float($0) }, + location: .bundle(bundle ?? Bundle.main), + label: nil, + decorative: true + ) + ) + } +} + +// MARK: - Image.Location + ProtobufMessage [WIP] + +extension Image.Location: ProtobufMessage { + fileprivate struct BundlePath: Hashable, ProtobufMessage { + var value: String + + init(value: String) { + self.value = value + } + + enum Error: Swift.Error { + case invalidPathData + } + + func encode(to encoder: inout ProtobufEncoder) throws { + // FIXME + try encoder.encodeAttachedValue(key: self) { + Data(value.utf8) + } + } + + init(from decoder: inout ProtobufDecoder) throws { + _openSwiftUIUnimplementedFailure() + } + } + + package func encode(to encoder: inout ProtobufEncoder) throws { + switch self { + case .bundle(let bundle): + try encoder.messageField(1, BundlePath(value: bundle.bundlePath)) + case .system: + encoder.emptyField(2) + case .privateSystem: + encoder.emptyField(3) + } + } + + private enum Error: Swift.Error { + case invalidBundle(String) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var result: Image.Location = .system + while let field = try decoder.nextField() { + switch field.tag { + case 1: + let bundlePath: BundlePath = try decoder.messageField(field) + guard let bundle = Bundle(path: bundlePath.value) else { + throw Error.invalidBundle(bundlePath.value) + } + result = .bundle(bundle) + case 2: + try decoder.messageField(field) { (_: inout ProtobufDecoder) in } + result = .system + case 3: + try decoder.messageField(field) { (_: inout ProtobufDecoder) in } + result = .privateSystem + default: + try decoder.skipField(field) + } + } + self = result + } +} + +// MARK: - Image.HashableScale + +extension Image { + /// A hashable representation of `Image.Scale` for use as a cache key. + package enum HashableScale: Hashable { + case small, medium, large + case ccSmall, ccMedium, ccLarge + case wtMedium + + package init(_ scale: Image.Scale) { + switch scale { + case .small: self = .small + case .medium: self = .medium + case .large: self = .large + case ._controlCenter_small: self = .ccSmall + case ._controlCenter_medium: self = .ccMedium + case ._controlCenter_large: self = .ccLarge + case ._watch_toolbar_medium: self = .wtMedium + } + } + + /// Maps image scale to CUI glyph size: small→1, medium→2, large→3. + fileprivate var glyphSize: Int { + switch self { + case .small, .ccSmall: return 1 + case .medium, .ccMedium, .wtMedium: return 2 + case .large, .ccLarge: return 3 + } + } + + /// Returns the allowed range for symbol size scaling. + /// + /// - For standard scales (small, medium, large): returns `1.0...1.0` (no scaling) + /// - For control center scales: reads from `NSUserDefaults` + /// `CCImageScale_MinimumScale` and `CCImageScale_MaximumScale` + /// - For watch toolbar medium: returns `0.75...1.0` + fileprivate var allowedScaleRange: ClosedRange { + switch self { + case .small, .medium, .large: + return 1.0 ... 1.0 + case .ccSmall, .ccMedium, .ccLarge: + #if canImport(Darwin) + let defaults = UserDefaults.standard + let lower = (defaults.value(forKey: "CCImageScale_MinimumScale") as? CGFloat) ?? 0.0 + let upper = (defaults.value(forKey: "CCImageScale_MaximumScale") as? CGFloat) ?? .greatestFiniteMagnitude + return lower ... upper + #else + return 0.0 ... .greatestFiniteMagnitude + #endif + case .wtMedium: + return 0.75 ... 1.0 + } + } + + /// Computes the diameter of a `circle.fill` symbol for the given point size and weight. + /// + /// The result is `interpolatedPercentage * 0.01 * pointSize`, where the + /// percentage is interpolated based on font weight between three known + /// values (ultraLight, regular, black). + /// + /// Weight interpolation constants per scale category + /// `(lightValue, nominalValue, heavyValue)`: + /// - `lightValue` is at weight -0.8 (ultraLight) + /// - `nominalValue` is at weight 0 (regular) + /// - `heavyValue` is at weight 0.62 (black) + /// + /// These represent the `circle.fill` diameter as a percentage of point size. + fileprivate func circleDotFillSize(pointSize: CGFloat, weight: Font.Weight) -> CGFloat { + let smallConstants: (light: Double, nominal: Double, heavy: Double) = (74.46, 78.86, 83.98) + let mediumConstants: (light: Double, nominal: Double, heavy: Double) = (94.63, 99.61, 106.64) + let largeConstants: (light: Double, nominal: Double, heavy: Double) = (121.66, 127.2, 135.89) + + let w = weight.value + let constants: (light: Double, nominal: Double, heavy: Double) + + switch self { + case .medium, .ccMedium, .wtMedium: + constants = mediumConstants + case .small, .ccSmall: + constants = smallConstants + default: // large, ccLarge + constants = largeConstants + } + + let percentage: CGFloat + if w == 0.0 { + percentage = constants.nominal + } else if w < 0.0 { + // Interpolate from light (at -0.8) to nominal (at 0) + percentage = constants.light + (w + 0.8) / 0.8 * (constants.nominal - constants.light) + } else { + // Interpolate from nominal (at 0) to heavy (at 0.62) + percentage = constants.nominal + w / 0.62 * (constants.heavy - constants.nominal) + } + + return percentage * 0.01 * pointSize + } + + /// Computes the maximum allowed radius from a given diameter. + /// + /// The base radius is `diameter / 2`. + /// - For control center scales, this is multiplied by a scale factor + /// read from `NSUserDefaults` `CCImageScale_CircleScale` (default 1.2). + /// - For watch toolbar medium, this is multiplied by 1.1. + fileprivate func maxRadius(diameter: CGFloat) -> CGFloat { + var radius = diameter * 0.5 + + switch self { + case .small, .medium, .large: + break + case .ccSmall, .ccMedium, .ccLarge: + #if canImport(Darwin) + let scale = (UserDefaults.standard.value(forKey: "CCImageScale_CircleScale") as? CGFloat) ?? 1.2 + #else + let scale: CGFloat = 1.2 + #endif + radius *= scale + case .wtMedium: + radius *= 1.1 + } + return radius + } + } +} + +// MARK: - UUIDImageProvider + +private struct UUIDImageProvider: ImageProvider { + var uuid: UUID + var size: CGSize + var label: Text? + + func resolve(in context: ImageResolutionContext) -> Image.Resolved { + let environment = context.environment + let isTemplate = environment.imageIsTemplate() + + let contents: GraphicsImage.Contents + if context.options.contains(.isArchived) { + contents = .named(.uuid(uuid)) + } else { + contents = .color(.init(linearRed: 1, linearGreen: 0, linearBlue: 1, opacity: 1)) + } + + var graphicsImage = GraphicsImage( + contents: contents, + scale: 1.0, + unrotatedPixelSize: size, + orientation: .up, + isTemplate: isTemplate + ) + graphicsImage.allowedDynamicRange = context.effectiveAllowedDynamicRange(for: graphicsImage) + if environment.shouldRedactContent { + graphicsImage.redact(in: environment) + } + return Image.Resolved( + image: graphicsImage, + decorative: label == nil, + label: AccessibilityImageLabel(label) + ) + } + + func resolveNamedImage(in context: ImageResolutionContext) -> Image.NamedResolved? { + nil + } +} + +// MARK: - Image.ResolvedUUID + +@_spi(Private) +@available(OpenSwiftUI_v4_0, *) +extension Image { + public init(uuid: UUID, size: CGSize, label: Text?) { + self.init(UUIDImageProvider(uuid: uuid, size: size, label: label)) + } + + public struct ResolvedUUID { + var cgImage: CGImage + var scale: CGFloat + var orientation: Image.Orientation + + package init(cgImage: CGImage, scale: CGFloat, orientation: Image.Orientation) { + self.cgImage = cgImage + self.scale = scale + self.orientation = orientation + } + } +} + +@_spi(Private) +@available(*, unavailable) +extension Image.ResolvedUUID: Sendable {} + +// MARK: - NamedImage.Key + ProtobufMessage [TBA] + +extension NamedImage.Key: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + switch self { + case .bitmap(let bitmapKey): + try encoder.messageField(1, bitmapKey) + case .uuid(let uuid): + try encoder.messageField(2, uuid) + } + } + + package init(from decoder: inout ProtobufDecoder) throws { + var result: NamedImage.Key? + while let field = try decoder.nextField() { + switch field.tag { + case 1: + result = .bitmap(try decoder.messageField(field)) + case 2: + result = .uuid(try decoder.messageField(field)) + default: + try decoder.skipField(field) + } + } + guard let result else { + throw ProtobufDecoder.DecodingError.failed + } + self = result + } +} + +// MARK: - NamedImage.BitmapKey + ProtobufMessage [TBA] + +extension NamedImage.BitmapKey: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + try encoder.messageField(1, catalogKey) + try encoder.stringField(2, name) + encoder.cgFloatField(3, scale, defaultValue: 1.0) + try encoder.messageField(4, location) + encoder.uintField(5, layoutDirection == .rightToLeft ? 1 : 0) + encoder.uintField(6, UInt(gamut.rawValue)) + encoder.intField(7, idiom) + encoder.intField(8, subtype) + encoder.uintField(9, UInt(horizontalSizeClass)) + encoder.uintField(10, UInt(verticalSizeClass)) + try encoder.messageField(11, locale) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var catalogKey = CatalogKey(colorScheme: .light, contrast: .standard) + var name = "" + var scale: CGFloat = 1.0 + var location: Image.Location = .system + var layoutDirection: LayoutDirection = .leftToRight + var gamut: DisplayGamut = .sRGB + var idiom: Int = 0 + var subtype: Int = 0 + var horizontalSizeClass: Int8 = 0 + var verticalSizeClass: Int8 = 0 + var locale: Locale = .current + + while let field = try decoder.nextField() { + switch field.tag { + case 1: catalogKey = try decoder.messageField(field) + case 2: name = try decoder.stringField(field) + case 3: scale = try decoder.cgFloatField(field) + case 4: location = try decoder.messageField(field) + case 5: + let value: UInt = try decoder.uintField(field) + layoutDirection = value != 0 ? .rightToLeft : .leftToRight + case 6: + let value: UInt = try decoder.uintField(field) + gamut = DisplayGamut(rawValue: Int(value)) ?? .sRGB + case 7: idiom = try decoder.intField(field) + case 8: subtype = try decoder.intField(field) + case 9: horizontalSizeClass = Int8(try decoder.uintField(field)) + case 10: verticalSizeClass = Int8(try decoder.uintField(field)) + case 11: locale = try decoder.messageField(field) + default: try decoder.skipField(field) + } + } + self.init( + catalogKey: catalogKey, + name: name, + scale: scale, + location: location, + layoutDirection: layoutDirection, + locale: locale, + gamut: gamut, + idiom: idiom, + subtype: subtype, + horizontalSizeClass: horizontalSizeClass, + verticalSizeClass: verticalSizeClass + ) + } +} + +#if OPENSWIFTUI_LINK_COREUI + +// MARK: - CUI Helpers + +extension Font.Weight { + /// Maps Font.Weight to CUI's `_CUIThemeVectorGlyphWeight` values. + /// + /// Matches each known weight value (within 0.001 tolerance): + /// - ultraLight (-0.8) → 1 + /// - thin (-0.6) → 2 + /// - light (-0.4) → 3 + /// - regular (0.0) → 4 + /// - medium (0.23) → 5 + /// - semibold (0.3) → 6 + /// - bold (0.4) → 7 + /// - heavy (0.56) → 8 + /// - black (0.62) → 9 + /// - unknown → 4 (regular) + fileprivate var glyphWeight: _CUIThemeVectorGlyphWeight { + let v = value + let tolerance = 0.001 + if abs(v - (-0.8)) < tolerance { return .ultraLight } + if abs(v - (-0.6)) < tolerance { return .thin } + if abs(v - (-0.4)) < tolerance { return .light } + if abs(v - 0.0) < tolerance { return .regular } + if abs(v - 0.23) < tolerance { return .medium } + if abs(v - 0.3) < tolerance { return .semibold } + if abs(v - 0.4) < tolerance { return .bold } + if abs(v - 0.56) < tolerance { return .heavy } + if abs(v - 0.62) < tolerance { return .black } + return .regular + } + + /// Maps Font.Weight to CUI's continuous weight CGFloat value. + /// + /// Looks up the `_CUIThemeVectorGlyphWeight`, then returns the corresponding + /// `_CUIVectorGlyphContinuousWeight*` constant from CoreUI. + fileprivate var glyphContinuousWeight: CGFloat { + switch glyphWeight { + case .ultraLight: return _CUIVectorGlyphContinuousWeightUltralight + case .thin: return _CUIVectorGlyphContinuousWeightThin + case .light: return _CUIVectorGlyphContinuousWeightLight + case .regular: return _CUIVectorGlyphContinuousWeightRegular + case .medium: return _CUIVectorGlyphContinuousWeightMedium + case .semibold: return _CUIVectorGlyphContinuousWeightSemibold + case .bold: return _CUIVectorGlyphContinuousWeightBold + case .heavy: return _CUIVectorGlyphContinuousWeightHeavy + case .black: return _CUIVectorGlyphContinuousWeightBlack + @unknown default: return _CUIVectorGlyphContinuousWeightRegular + } + } +} + +extension LayoutDirection { + /// Converts SwiftUI LayoutDirection to CUI's layout direction. + fileprivate var cuiLayoutDirection: CUILayoutDirection { + switch self { + case .leftToRight: return .LTR + case .rightToLeft: return .RTL + } + } +} + +#endif + +#if canImport(Darwin) && canImport(DeveloperToolsSupport) + +public import DeveloperToolsSupport + +// MARK: - Image + ImageResource [TODO] + +extension Image { + /// Initialize a `Image` with a image resource. + public init(_ resource: ImageResource) { + _openSwiftUIUnimplementedFailure() } } +#endif diff --git a/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift b/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift index 7a082afb7..1fa077c0e 100644 --- a/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift @@ -8,9 +8,12 @@ package import OpenAttributeGraphShims package import OpenCoreGraphicsShims +#if OPENSWIFTUI_LINK_COREUI +package import CoreUI +#endif extension Image { - // MARK: - Image.LayoutMetrics [WIP] + // MARK: - Image.LayoutMetrics package struct LayoutMetrics: Equatable { package var baselineOffset: CGFloat @@ -36,7 +39,71 @@ extension Image { self.backgroundSize = .zero } - // TODO: CUINamedVectorGlyph + #if OPENSWIFTUI_LINK_COREUI + package mutating func adjustForBackground( + glyph: CUINamedVectorGlyph, + shape: SymbolVariants.Shape, + size: inout CGSize, + growsToFitBackground: Bool + ) { +// let options = CUIVectorGlyphGraphicVariantOptions() +// switch shape { +// case .circle: +// options.shape = 1 +// default: +// break +// } +// options.imageScaling = growsToFitBackground ? 1 : 3 +// guard let variant = glyph.graphicVariant(with: options) else { +// return +// } +// let interiorAlignment = variant.interiorAlignmentRect +// size = CGSize(width: interiorAlignment.width, height: interiorAlignment.height) +// baselineOffset = variant.baselineOffset +// capHeight = variant.capHeight +// let contentBounds = variant.contentBounds +// contentSize = contentBounds.size +// let alignmentRect = variant.alignmentRect +// alignmentOrigin = alignmentRect.origin +// backgroundSize = CGSize(width: alignmentRect.width, height: alignmentRect.height) + } + + package init(glyph: CUINamedVectorGlyph, flipsRightToLeft: Bool) { + let baselineOffset = glyph.baselineOffset + let capHeight = glyph.capHeight + + let contentSize: CGSize + let alignmentOrigin: CGPoint + + if Semantics.SymbolImageLayoutUsingContentBounds.isEnabled { + let alignmentRect = glyph.alignmentRect + let contentBounds = glyph.contentBounds + contentSize = contentBounds.size + let originY = alignmentRect.origin.y + let originX: CGFloat + if flipsRightToLeft { + originX = contentSize.width - alignmentRect.maxX + } else { + originX = alignmentRect.origin.x + } + alignmentOrigin = CGPoint(x: originX, y: originY) + } else if Semantics.ImagesLayoutAsText.isEnabled { + let alignmentRect = glyph.alignmentRect + contentSize = alignmentRect.size + alignmentOrigin = .zero + } else { + let alignmentRect = glyph.alignmentRect + contentSize = CGSize(width: alignmentRect.width, height: capHeight) + alignmentOrigin = CGPoint(x: 0, y: -baselineOffset) + } + self.init( + baselineOffset: baselineOffset, + capHeight: capHeight, + contentSize: contentSize, + alignmentOrigin: alignmentOrigin + ) + } + #endif } // MARK: - Image.Resolved diff --git a/Sources/OpenSwiftUICore/View/Image/SymbolVariants.swift b/Sources/OpenSwiftUICore/View/Image/SymbolVariants.swift index b731ce4cb..20f42adad 100644 --- a/Sources/OpenSwiftUICore/View/Image/SymbolVariants.swift +++ b/Sources/OpenSwiftUICore/View/Image/SymbolVariants.swift @@ -327,6 +327,18 @@ public struct SymbolVariants: Hashable, Sendable { shape = other.shape ?? shape } + /// Normalizes symbol variants for name lookup by handling background semantics. + /// + /// When the `.background` variant is set, the fill flag is toggled, + /// the background flag is cleared, and the shape is removed. + package func _normalizedForNameLookup() -> SymbolVariants { + guard flags.contains(.background) else { return self } + var newFlags = flags + newFlags.formSymmetricDifference(.fill) + newFlags.remove(.background) + return SymbolVariants(flags: newFlags, shape: nil) + } + /// Returns a Boolean value that indicates whether the current variant /// contains the specified variant. /// @@ -461,6 +473,24 @@ private struct SymbolsGrowToFitBackgroundKey: EnvironmentKey { @available(OpenSwiftUI_v3_0, *) extension EnvironmentValues { + + /// The symbol variant to use in this environment. + /// + /// You set this environment value indirectly by using the + /// ``View/symbolVariant(_:)`` view modifier. However, you access the + /// environment variable directly using the ``View/environment(_:_:)`` + /// modifier. Do this when you want to use the ``SymbolVariants/none`` + /// variant to ignore the value that's already in the environment: + /// + /// HStack { + /// Image(systemName: "heart") + /// Image(systemName: "heart") + /// .environment(\.symbolVariants, .none) + /// } + /// .symbolVariant(.fill) + /// + /// ![A screenshot of two heart symbols. The first is filled while the + /// second is outlined.](SymbolVariants-none-1) public var symbolVariants: SymbolVariants { get { self[SymbolVariantsKey.self] } set { self[SymbolVariantsKey.self] = newValue } diff --git a/Sources/OpenSwiftUICore/View/Text/Font/CoreText+Private.swift b/Sources/OpenSwiftUICore/View/Text/Font/CoreText+Private.swift index 6930b8ef4..09bc4dfde 100644 --- a/Sources/OpenSwiftUICore/View/Text/Font/CoreText+Private.swift +++ b/Sources/OpenSwiftUICore/View/Text/Font/CoreText+Private.swift @@ -41,6 +41,9 @@ package func CTFontIsSystemUIFont(_ font: CTFont) -> Bool @_silgen_name("CTFontGetWeight") package func CTFontGetWeight(_ font: CTFont) -> CGFloat +@_silgen_name("CTFontGetAccessibilityBoldWeightOfWeight") +package func CTFontGetAccessibilityBoldWeightOfWeight(_ weight: CGFloat) -> CGFloat + // MARK: - TraitKey @_silgen_name("kCTFontUIFontDesignTrait") diff --git a/Sources/OpenSwiftUI_SPI/Shims/CoreGraphics/CoreGraphics_Private.h b/Sources/OpenSwiftUI_SPI/Shims/CoreGraphics/CoreGraphics_Private.h index 355f66181..c76f2c247 100644 --- a/Sources/OpenSwiftUI_SPI/Shims/CoreGraphics/CoreGraphics_Private.h +++ b/Sources/OpenSwiftUI_SPI/Shims/CoreGraphics/CoreGraphics_Private.h @@ -14,6 +14,9 @@ OPENSWIFTUI_ASSUME_NONNULL_BEGIN OPENSWIFTUI_EXPORT bool CGImageGetHeadroom(CGImageRef cg_nullable image, float cg_nullable *headroom); +OPENSWIFTUI_EXPORT +void CGImageSetProperty(CGImageRef image, CFStringRef key, const void * cg_nullable value); + OPENSWIFTUI_ASSUME_NONNULL_END #endif /* CoreGraphics.h */ diff --git a/Sources/OpenSwiftUI_SPI/Shims/GraphicsServices/GraphicsServices.h b/Sources/OpenSwiftUI_SPI/Shims/GraphicsServices/GraphicsServices.h new file mode 100644 index 000000000..99a9ce4d7 --- /dev/null +++ b/Sources/OpenSwiftUI_SPI/Shims/GraphicsServices/GraphicsServices.h @@ -0,0 +1,22 @@ +// +// GraphicsServices.h +// OpenSwiftUI_SPI +// +// Status: Complete + +#ifndef GraphicsServices_h +#define GraphicsServices_h + +#include "OpenSwiftUIBase.h" + +#if OPENSWIFTUI_TARGET_OS_DARWIN + +OPENSWIFTUI_ASSUME_NONNULL_BEGIN + +NSString * _SimulatorSystemRootDirectory(void); + +OPENSWIFTUI_ASSUME_NONNULL_END + +#endif /* OPENSWIFTUI_TARGET_OS_DARWIN */ + +#endif /* GraphicsServices_h */ diff --git a/Sources/OpenSwiftUI_SPI/Shims/GraphicsServices/GraphicsServices.m b/Sources/OpenSwiftUI_SPI/Shims/GraphicsServices/GraphicsServices.m new file mode 100644 index 000000000..b82f62114 --- /dev/null +++ b/Sources/OpenSwiftUI_SPI/Shims/GraphicsServices/GraphicsServices.m @@ -0,0 +1,23 @@ +// +// GraphicsServices.m +// OpenSwiftUI_SPI +// +// Status: Complete + +#include "GraphicsServices.h" + +#if OPENSWIFTUI_TARGET_OS_DARWIN + +#include + +NSString * _SimulatorSystemRootDirectory(void) { + typedef NSString * (*GSSystemRootDirectoryFunc)(void); + static GSSystemRootDirectoryFunc gsFunc; + static dispatch_once_t once; + dispatch_once(&once, ^{ + gsFunc = (GSSystemRootDirectoryFunc)dlsym(RTLD_DEFAULT, "GSSystemRootDirectory"); + }); + return gsFunc ? gsFunc() : @""; +} + +#endif /* OPENSWIFTUI_TARGET_OS_DARWIN */ diff --git a/Sources/OpenSwiftUI_SPI/module.modulemap b/Sources/OpenSwiftUI_SPI/module.modulemap index dc26a84a2..75a6ac57c 100644 --- a/Sources/OpenSwiftUI_SPI/module.modulemap +++ b/Sources/OpenSwiftUI_SPI/module.modulemap @@ -48,6 +48,11 @@ module kdebug_Private { export * } +module GraphicsServices_Private { + umbrella "Shims/GraphicsServices" + export * +} + module MobileGestaltPrivate { umbrella "Shims/MobileGestalt" export * diff --git a/Tests/OpenSwiftUICoreTests/Util/SFSymbolsShimsTests.swift b/Tests/OpenSwiftUICoreTests/Util/SFSymbolsShimsTests.swift new file mode 100644 index 000000000..ce362c80d --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Util/SFSymbolsShimsTests.swift @@ -0,0 +1,48 @@ +// +// SFSymbolsShimsTests.swift +// OpenSwiftUICoreTests + +#if canImport(Darwin) +import OpenSwiftUICore +import Testing + +struct SFSymbolsShimsTests { + @Test + func symbolOrder() { + let order = SFSymbols.symbol_order + #expect(!order.isEmpty) + #expect(order.contains("star")) + #expect(order.contains("heart")) + } + + @Test + func privateSymbolOrder() { + let order = SFSymbols.private_symbol_order + #expect(!order.isEmpty) + } + + @Test + func nameAliases() { + let aliases = SFSymbols.name_aliases + #expect(!aliases.isEmpty) + } + + @Test + func privateNameAliases() { + let aliases = SFSymbols.private_name_aliases + #expect(!aliases.isEmpty) + } + + @Test + func nofillToFill() { + let mapping = SFSymbols.nofill_to_fill + #expect(!mapping.isEmpty) + } + + @Test + func privateNofillToFill() { + let mapping = SFSymbols.private_nofill_to_fill + #expect(!mapping.isEmpty) + } +} +#endif diff --git a/Tests/OpenSwiftUICoreTests/View/Image/NamedImageTests.swift b/Tests/OpenSwiftUICoreTests/View/Image/NamedImageTests.swift new file mode 100644 index 000000000..27b997e96 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/View/Image/NamedImageTests.swift @@ -0,0 +1,484 @@ +// +// NamedImageTests.swift +// OpenSwiftUICoreTests + +import Foundation +import OpenCoreGraphicsShims +@_spi(Private) +@testable +#if OPENSWIFTUI_ENABLE_PRIVATE_IMPORTS +@_private(sourceFile: "NamedImage.swift") +#endif +import OpenSwiftUICore +import Testing + +// MARK: - NamedImage.Errors Tests + +struct NamedImageErrorsTests { + @Test + func equality() { + let a = NamedImage.Errors.missingCatalogImage + let b = NamedImage.Errors.missingUUIDImage + #expect(a == a) + #expect(b == b) + #expect(a != b) + } + + @Test + func hashing() { + let a = NamedImage.Errors.missingCatalogImage + let b = NamedImage.Errors.missingUUIDImage + #expect(a.hashValue != b.hashValue) + #expect(a.hashValue == NamedImage.Errors.missingCatalogImage.hashValue) + } + + @Test + func conformsToError() { + let error: any Error = NamedImage.Errors.missingCatalogImage + #expect(error is NamedImage.Errors) + } +} + +// MARK: - NamedImage.Key Tests + +struct NamedImageKeyTests { + @Test + func bitmapKeyEquality() { + let key1 = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "test", + scale: 2.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + let key2 = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "test", + scale: 2.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + #expect(key1 == key2) + } + + @Test + func bitmapKeyInequality() { + let key1 = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "image_a", + scale: 2.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + let key2 = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "image_b", + scale: 2.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + #expect(key1 != key2) + } + + @Test + func bitmapKeyHashing() { + let key1 = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "test", + scale: 2.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + let key2 = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "test", + scale: 2.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + #expect(key1.hashValue == key2.hashValue) + } + + @Test + func bitmapKeyDifferentNamesProduceDifferentHashes() { + let key1 = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "alpha", + scale: 1.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + let key2 = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "beta", + scale: 1.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + #expect(key1.hashValue != key2.hashValue) + } + + @Test + func bitmapKeySizeClassDefaults() { + let key = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "test", + scale: 1.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + #expect(key.horizontalSizeClass == 0) + #expect(key.verticalSizeClass == 0) + } + + @Test + func keyEquality() { + let bitmapKey = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "test", + scale: 1.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + let key1 = NamedImage.Key.bitmap(bitmapKey) + let key2 = NamedImage.Key.bitmap(bitmapKey) + #expect(key1 == key2) + } + + @Test + func keyInequalityDifferentCases() { + let bitmapKey = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "test", + scale: 1.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + let key1 = NamedImage.Key.bitmap(bitmapKey) + let key2 = NamedImage.Key.uuid(UUID()) + #expect(key1 != key2) + } + + @Test + func keyUUIDEquality() { + let uuid = UUID() + let key1 = NamedImage.Key.uuid(uuid) + let key2 = NamedImage.Key.uuid(uuid) + #expect(key1 == key2) + } + + @Test + func keyUUIDInequality() { + let key1 = NamedImage.Key.uuid(UUID()) + let key2 = NamedImage.Key.uuid(UUID()) + #expect(key1 != key2) + } +} + +// MARK: - Image.Location Tests + +struct ImageLocationTests { + @Test + func systemEquality() { + #expect(Image.Location.system == Image.Location.system) + } + + @Test + func privateSystemEquality() { + #expect(Image.Location.privateSystem == Image.Location.privateSystem) + } + + @Test + func bundleEquality() { + let bundle = Bundle.main + #expect(Image.Location.bundle(bundle) == Image.Location.bundle(bundle)) + } + + @Test + func differentCasesNotEqual() { + #expect(Image.Location.system != Image.Location.privateSystem) + #expect(Image.Location.system != Image.Location.bundle(.main)) + #expect(Image.Location.privateSystem != Image.Location.bundle(.main)) + } + + @Test + func supportsNonVectorImages() { + #expect(Image.Location.bundle(.main).supportsNonVectorImages == true) + #expect(Image.Location.system.supportsNonVectorImages == false) + #expect(Image.Location.privateSystem.supportsNonVectorImages == false) + } + + @Test + func bundleAccessor() { + let bundle = Bundle.main + #expect(Image.Location.bundle(bundle).bundle === bundle) + #expect(Image.Location.system.bundle == nil) + #expect(Image.Location.privateSystem.bundle == nil) + } + + @Test + func hashConsistency() { + let loc1 = Image.Location.system + let loc2 = Image.Location.system + #expect(loc1.hashValue == loc2.hashValue) + } + + @Test + func hashDifferentCases() { + let systemHash = Image.Location.system.hashValue + let privateHash = Image.Location.privateSystem.hashValue + let bundleHash = Image.Location.bundle(.main).hashValue + // All three should be different (technically not guaranteed, but highly likely) + #expect(systemHash != privateHash) + #expect(systemHash != bundleHash) + } +} + +// MARK: - Image.HashableScale Tests + +struct HashableScaleTests { + @Test + func initFromScale() { + #expect(Image.HashableScale(.small) == .small) + #expect(Image.HashableScale(.medium) == .medium) + #expect(Image.HashableScale(.large) == .large) + } +} + +// MARK: - NamedImage.Cache Tests + +struct NamedImageCacheTests { + @Test + func decodeThrowsMissingCatalogImage() { + let cache = NamedImage.Cache() + let bitmapKey = NamedImage.BitmapKey( + catalogKey: CatalogKey(colorScheme: .light, contrast: .standard), + name: "missing", + scale: 1.0, + location: .system, + layoutDirection: .leftToRight, + locale: .autoupdatingCurrent, + gamut: .sRGB, + idiom: 0, + subtype: 0 + ) + let key = NamedImage.Key.bitmap(bitmapKey) + #expect(throws: NamedImage.Errors.missingCatalogImage) { + try cache.decode(key) + } + } + + @Test + func decodeThrowsMissingUUIDImage() { + let cache = NamedImage.Cache() + let key = NamedImage.Key.uuid(UUID()) + #expect(throws: NamedImage.Errors.missingUUIDImage) { + try cache.decode(key) + } + } + + @Test + func sharedCacheExists() { + let cache = NamedImage.sharedCache + _ = cache // Access should not crash + } +} + +// MARK: - NamedImageProvider Tests + +struct NamedImageProviderTests { + @Test + func equality() { + let provider1 = Image.NamedImageProvider( + name: "star", + location: .system, + label: nil, + decorative: false + ) + let provider2 = Image.NamedImageProvider( + name: "star", + location: .system, + label: nil, + decorative: false + ) + #expect(provider1 == provider2) + } + + @Test + func inequalityDifferentName() { + let provider1 = Image.NamedImageProvider( + name: "star", + location: .system, + label: nil, + decorative: false + ) + let provider2 = Image.NamedImageProvider( + name: "heart", + location: .system, + label: nil, + decorative: false + ) + #expect(provider1 != provider2) + } + + @Test + func inequalityDifferentLocation() { + let provider1 = Image.NamedImageProvider( + name: "star", + location: .system, + label: nil, + decorative: false + ) + let provider2 = Image.NamedImageProvider( + name: "star", + location: .bundle(.main), + label: nil, + decorative: false + ) + #expect(provider1 != provider2) + } + + @Test + func inequalityDifferentDecorative() { + let provider1 = Image.NamedImageProvider( + name: "star", + location: .system, + label: nil, + decorative: false + ) + let provider2 = Image.NamedImageProvider( + name: "star", + location: .system, + label: nil, + decorative: true + ) + #expect(provider1 != provider2) + } + + @Test + func valueProperty() { + let provider = Image.NamedImageProvider( + name: "slider", + value: 0.5, + location: .system, + label: nil, + decorative: false + ) + #expect(provider.value == 0.5) + } + + @Test + func resolveErrorScale() { + let provider = Image.NamedImageProvider( + name: "nonexistent", + location: .system, + label: nil, + decorative: true + ) + let environment = EnvironmentValues() + let resolved = provider.resolveError(in: environment) + #expect(resolved.image.scale == 0) + #expect(resolved.image.contents == nil) + #expect(resolved.decorative == true) + } + + @Test + func resolveNamedImageReturnsNilForMissingImage() { + let provider = Image.NamedImageProvider( + name: "nonexistent_image_xyz", + location: .bundle(.main), + label: nil, + decorative: false + ) + let context = ImageResolutionContext(environment: EnvironmentValues()) + let result = provider.resolveNamedImage(in: context) + #expect(result == nil) + } + + @Test + func equalityFieldOrder() { + let provider1 = Image.NamedImageProvider( + name: "star", + value: 0.5, + location: .system, + label: nil, + decorative: false, + backupLocation: nil + ) + let provider2 = Image.NamedImageProvider( + name: "star", + value: 0.5, + location: .system, + label: nil, + decorative: false, + backupLocation: nil + ) + #expect(provider1 == provider2) + + // Different backupLocation should cause inequality + let provider3 = Image.NamedImageProvider( + name: "star", + value: 0.5, + location: .system, + label: nil, + decorative: false, + backupLocation: .privateSystem + ) + #expect(provider1 != provider3) + + // Different value should cause inequality + let provider4 = Image.NamedImageProvider( + name: "star", + value: 0.75, + location: .system, + label: nil, + decorative: false, + backupLocation: nil + ) + #expect(provider1 != provider4) + } +}