From 3d08ab5ed575ae5940c0067d0f0851428012765d Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 27 Feb 2026 23:49:20 +0800 Subject: [PATCH 01/19] Add NamedImage support --- .../View/Image/Image+NamedImage.swift | 228 ++++++++ .../View/Image/ImageScale.swift | 125 ++++ .../View/Image/NamedImage.swift | 533 +++++++++++++++++- .../View/Image/NamedImageProvider.swift | 89 +++ .../View/Text/Font/CoreText+Private.swift | 3 + .../View/Image/NamedImageTests.swift | 413 ++++++++++++++ 6 files changed, 1387 insertions(+), 4 deletions(-) create mode 100644 Sources/OpenSwiftUICore/View/Image/Image+NamedImage.swift create mode 100644 Sources/OpenSwiftUICore/View/Image/ImageScale.swift create mode 100644 Sources/OpenSwiftUICore/View/Image/NamedImageProvider.swift create mode 100644 Tests/OpenSwiftUICoreTests/View/Image/NamedImageTests.swift diff --git a/Sources/OpenSwiftUICore/View/Image/Image+NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/Image+NamedImage.swift new file mode 100644 index 000000000..0f32b3376 --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Image/Image+NamedImage.swift @@ -0,0 +1,228 @@ +// +// Image+NamedImage.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: 8E7DCD4CEB1ACDE07B249BFF4CBC75C0 (SwiftUICore) + +public import Foundation + +// MARK: - Image named initializers + +@available(OpenSwiftUI_v1_0, *) +extension Image { + /// Creates a named image. + /// + /// Use this initializer to load an image stored in your app's asset + /// catalog by name. OpenSwiftUI treats the image as accessory-level by + /// default. + /// + /// Use the ``Image/init(_:bundle:label:)`` initializer instead if you + /// want to provide accessibility information about the image. + /// + /// - Parameters: + /// - name: The name of the image resource to look up. + /// - bundle: The bundle in which to search for the image resource. If + /// you don't indicate a bundle, the initializer looks in your app's + /// main bundle by default. + public init(_ name: String, bundle: Bundle? = nil) { + self.init( + NamedImageProvider( + name: name, + location: .bundle(bundle ?? Bundle.main), + label: AccessibilityImageLabel(name), + decorative: false + ) + ) + } + + /// Creates a labeled named image. + /// + /// Creates an image by looking for a named resource in the specified + /// bundle. The system uses the provided label text for accessibility. + /// + /// - Parameters: + /// - name: The name of the image resource to look up. + /// - bundle: The bundle in which to search for the image resource. + /// If you don't indicate a bundle, the initializer looks in your app's + /// main bundle by default. + /// - label: The label text to use 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 a decorative named image. + /// + /// Creates an image by looking for a named resource in the specified + /// bundle. The accessibility system ignores decorative images. + /// + /// - Parameters: + /// - name: The name of the image resource to look up. + /// - bundle: The bundle in which to search for the image resource. + /// If you don't indicate a bundle, the initializer looks in your app's + /// main bundle by default. + 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. + /// + /// Use this initializer to load an SF Symbols image by name. + /// + /// - Parameter systemName: The name of the system symbol image. + @available(macOS, introduced: 11.0) + public init(systemName: String) { + self.init( + NamedImageProvider( + name: systemName, + location: .system, + label: .systemSymbol(systemName), + decorative: false + ) + ) + } + + /// Creates a system symbol image for internal use. + /// + /// - Parameter systemName: The name of the system symbol image. + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + public init(_internalSystemName systemName: String) { + self.init( + NamedImageProvider( + name: systemName, + location: .system, + label: .systemSymbol(systemName), + decorative: false, + backupLocation: .privateSystem + ) + ) + } +} + +// MARK: - Image named initializers with variableValue + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension Image { + /// Creates a named image with a variable value. + /// + /// This initializer creates an image using a named image resource, with + /// an optional variable value that some symbol images use to customize + /// their appearance. + /// + /// - Parameters: + /// - name: The name of the image resource to look up. + /// - 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 colors, this + /// parameter has no effect. Use the SF Symbols app to look up which + /// symbols support variable colors. + /// - bundle: The bundle in which to search for the image resource. + /// If you don't indicate a bundle, the initializer looks in your app's + /// main bundle by default. + 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(name), + decorative: false + ) + ) + } + + /// Creates a labeled named image with a variable value. + /// + /// - Parameters: + /// - name: The name of the image resource to look up. + /// - variableValue: An optional value between `0.0` and `1.0` that + /// the rendered image can use to customize its appearance. + /// - bundle: The bundle in which to search for the image resource. + /// If you don't indicate a bundle, the initializer looks in your app's + /// main bundle by default. + /// - label: The label text to use 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 a decorative named image with a variable value. + /// + /// - Parameters: + /// - name: The name of the image resource to look up. + /// - variableValue: An optional value between `0.0` and `1.0` that + /// the rendered image can use to customize its appearance. + /// - bundle: The bundle in which to search for the image resource. + /// If you don't indicate a bundle, the initializer looks in your app's + /// main bundle by default. + 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 + ) + ) + } + + /// Creates a system symbol image with a variable value. + /// + /// - Parameters: + /// - systemName: The name of the system symbol image. + /// - variableValue: An optional value between `0.0` and `1.0` that + /// the rendered image can use to customize its appearance. + public init(systemName: String, variableValue: Double?) { + self.init( + NamedImageProvider( + name: systemName, + value: variableValue.map { Float($0) }, + location: .system, + label: .systemSymbol(systemName), + decorative: false + ) + ) + } + + /// Creates a system symbol image with a variable value for internal use. + /// + /// - Parameters: + /// - systemName: The name of the system symbol image. + /// - variableValue: An optional value between `0.0` and `1.0` that + /// the rendered image can use to customize its appearance. + public init(_internalSystemName systemName: String, variableValue: Double?) { + self.init( + NamedImageProvider( + name: systemName, + value: variableValue.map { Float($0) }, + location: .system, + label: .systemSymbol(systemName), + decorative: false, + backupLocation: .privateSystem + ) + ) + } +} diff --git a/Sources/OpenSwiftUICore/View/Image/ImageScale.swift b/Sources/OpenSwiftUICore/View/Image/ImageScale.swift new file mode 100644 index 000000000..e61202c9e --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Image/ImageScale.swift @@ -0,0 +1,125 @@ +// +// ImageScale.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: 8E7DCD4CEB1ACDE07B249BFF4CBC75C0 (SwiftUICore) +#if canImport(Darwin) +package import Foundation +#endif + +extension Image { + /// A hashable representation of `Image.Scale` for use as a cache key. + package enum HashableScale: Hashable { + case small + case medium + case large + case ccSmall + case ccMedium + case ccLarge + + 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 + default: self = .medium + } + } + + // MARK: - Helpers for symbol sizing + + /// 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" + package 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 + precondition(lower <= upper, "xx") + return lower ... upper + #else + return 0.0 ... .greatestFiniteMagnitude + #endif + } + } + + // Weight interpolation constants per scale category: + // (lightValue, nominalValue, heavyValue) + // where 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. + private static let smallConstants: (light: Double, nominal: Double, heavy: Double) = (74.46, 78.86, 83.98) + private static let mediumConstants: (light: Double, nominal: Double, heavy: Double) = (94.63, 99.61, 106.64) + private static let largeConstants: (light: Double, nominal: Double, heavy: Double) = (121.66, 127.2, 135.89) + + /// 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). + package func circleDotFillSize(pointSize: CGFloat, weight: Font.Weight) -> CGFloat { + let w = weight.value + let constants: (light: Double, nominal: Double, heavy: Double) + + // Discriminator bitmask: medium/ccMedium = 0x52, small/ccSmall = 0x9 + switch self { + case .medium, .ccMedium: + constants = Self.mediumConstants + case .small, .ccSmall: + constants = Self.smallConstants + default: // large, ccLarge + constants = Self.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). + package 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 + } + + return radius + } + } +} diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index eefd1fd0a..c0f39e395 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -3,20 +3,376 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: Empty +// Status: Complete // ID: 8E7DCD4CEB1ACDE07B249BFF4CBC75C0 (SwiftUICore) package import Foundation +package import OpenCoreGraphicsShims + +#if OPENSWIFTUI_LINK_COREUI +package import CoreUI +#endif + +// MARK: - NamedImage -// TODO package enum NamedImage { + + // MARK: - NamedImage.VectorKey [WIP] + + 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 + + // TODO: loadVectorInfo + + // [TBA] + /// 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 1.0 + } + + guard let layers = glyph.monochromeLayers as? [CUIVectorGlyphLayer] 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 } + + // 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 + } + + #if OPENSWIFTUI_LINK_COREUI + // MARK: - VectorInfo + + package struct VectorInfo { + package var glyph: CUINamedVectorGlyph + + package var flipsRightToLeft: Bool + + package var layoutMetrics: Image.LayoutMetrics + } + #endif + + // MARK: - NamedImage.BitmapKey [WIP] + + 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 + } + } + + // TODO: loadBitmapInfo + } + + // 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 [TODO] + + package struct Cache { + private struct ImageCacheData { + var bitmaps: [NamedImage.BitmapKey: NamedImage.BitmapInfo] = [:] + var uuids: [UUID: NamedImage.DecodedInfo] = [:] + #if canImport(Darwin) && OPENSWIFTUI_LINK_COREUI + var catalogs: [URL: WeakCatalog] = [:] + #endif + } + + #if canImport(Darwin) && OPENSWIFTUI_LINK_COREUI + struct WeakCatalog { + weak var catalog: CUICatalog? + } + #endif + + @AtomicBox + private var _data: ImageCacheData + + package init() { + self.__data = AtomicBox(wrappedValue: ImageCacheData()) + } + + // MARK: Cache subscripts + + #if canImport(Darwin) && OPENSWIFTUI_LINK_COREUI + package subscript(key: BitmapKey, location: Image.Location) -> BitmapInfo? { + // TODO: Full CoreUI bitmap lookup implementation + get { nil } + } + + package subscript(bundle: Bundle) -> (CUICatalog, retain: Bool)? { + // TODO: Full CoreUI catalog lookup implementation + get { nil } + } + #endif + + package func decode(_ key: Key) throws -> DecodedInfo { + switch key { + case .bitmap: + throw Errors.missingCatalogImage + case .uuid: + throw Errors.missingUUIDImage + } + } + } + + // MARK: - NamedImage.sharedCache + + private static let _sharedCache = Cache() + + package static var sharedCache: Cache { + _sharedCache + } } +// TODO: WIP + +// MARK: - Image.Location + extension Image { - // TODO package enum Location: Equatable, Hashable { case bundle(Bundle) case system @@ -29,7 +385,18 @@ extension Image { return true } - // package var catalog: CUICatalog? + #if canImport(Darwin) && OPENSWIFTUI_LINK_COREUI + package var catalog: CUICatalog? { + switch self { + case .bundle(let bundle): + return NamedImage.sharedCache[bundle]?.0 + case .system: + return nil + case .privateSystem: + return nil + } + } + #endif package var bundle: Bundle? { guard case .bundle(let bundle) = self else { @@ -37,5 +404,163 @@ extension Image { } return bundle } + + package static func == (a: Image.Location, b: Image.Location) -> Bool { + switch (a, b) { + case let (.bundle(lhs), .bundle(rhs)): + return lhs == rhs + case (.system, .system): + return true + case (.privateSystem, .privateSystem): + return true + default: + return false + } + } + + package func hash(into hasher: inout Hasher) { + switch self { + case .bundle(let bundle): + hasher.combine(0) + hasher.combine(bundle.bundleURL) + case .system: + hasher.combine(1) + case .privateSystem: + hasher.combine(2) + } + } + } +} + +// MARK: - NamedImage.Key + ProtobufMessage + +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: + // TODO: UUID protobuf encoding + break + } + } + + 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)) + default: + try decoder.skipField(field) + } + } + guard let result else { + throw ProtobufDecoder.DecodingError.failed + } + self = result + } +} + +// MARK: - NamedImage.BitmapKey + ProtobufMessage + +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) + try encoder.messageField(4, location) + encoder.intField(5, layoutDirection == .rightToLeft ? 1 : 0) + // locale encoding omitted - Locale does not yet conform to ProtobufMessage + encoder.intField(7, gamut.rawValue) + encoder.intField(8, idiom) + encoder.intField(9, subtype) + encoder.intField(10, Int(horizontalSizeClass)) + encoder.intField(11, Int(verticalSizeClass)) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var catalogKey = CatalogKey(colorScheme: .light, contrast: .standard) + var name = "" + var scale: CGFloat = 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 + + 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: Int = try decoder.intField(field) + layoutDirection = value == 1 ? .rightToLeft : .leftToRight + case 7: + let value: Int = try decoder.intField(field) + gamut = DisplayGamut(rawValue: value) ?? .sRGB + case 8: idiom = try decoder.intField(field) + case 9: subtype = try decoder.intField(field) + case 10: horizontalSizeClass = Int8(try decoder.intField(field) as Int) + case 11: verticalSizeClass = Int8(try decoder.intField(field) as Int) + default: try decoder.skipField(field) + } + } + self.init( + catalogKey: catalogKey, + name: name, + scale: scale, + location: location, + layoutDirection: layoutDirection, + locale: .autoupdatingCurrent, + gamut: gamut, + idiom: idiom, + subtype: subtype, + horizontalSizeClass: horizontalSizeClass, + verticalSizeClass: verticalSizeClass + ) + } +} + +// MARK: - Image.Location + ProtobufMessage + +extension Image.Location: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + switch self { + case .bundle(let bundle): + encoder.intField(1, 2) + try encoder.stringField(2, bundle.bundlePath) + case .system: + encoder.intField(1, 0) + case .privateSystem: + encoder.intField(1, 1) + } + } + + package init(from decoder: inout ProtobufDecoder) throws { + var discriminator: Int = 0 + var path: String? + while let field = try decoder.nextField() { + switch field.tag { + case 1: discriminator = try decoder.intField(field) + case 2: path = try decoder.stringField(field) + default: try decoder.skipField(field) + } + } + switch discriminator { + case 0: self = .system + case 1: self = .privateSystem + case 2: + if let path, let bundle = Bundle(path: path) { + self = .bundle(bundle) + } else { + self = .system + } + default: self = .system + } } } diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImageProvider.swift b/Sources/OpenSwiftUICore/View/Image/NamedImageProvider.swift new file mode 100644 index 000000000..0a26fb3d1 --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Image/NamedImageProvider.swift @@ -0,0 +1,89 @@ +// +// NamedImageProvider.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: WIP +// ID: ? (SwiftUICore) + +package import Foundation +package import OpenCoreGraphicsShims + +// MARK: - Image.NamedImageProvider + +extension Image { + 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 { + // TODO: Full CoreUI-based resolution + // The real implementation: + // 1. Tries vector resolution first (via vectorInfo) + // 2. Falls back to bitmap resolution (via bitmapInfo) + // 3. Returns resolveError if both fail + resolveError(in: context.environment) + } + + package func resolveError(in environment: EnvironmentValues) -> Image.Resolved { + Image.Resolved( + image: GraphicsImage( + contents: nil, + scale: environment.displayScale, + unrotatedPixelSize: .zero, + orientation: .up, + isTemplate: false + ), + decorative: decorative, + label: label + ) + } + + package func resolveNamedImage(in context: ImageResolutionContext) -> Image.NamedResolved? { + let environment = context.environment + let isTemplate = environment.imageIsTemplate() + return Image.NamedResolved( + name: name, + location: location, + value: value, + symbolRenderingMode: context.symbolRenderingMode?.storage, + isTemplate: isTemplate, + environment: environment + ) + } + + package static func == (a: NamedImageProvider, b: NamedImageProvider) -> Bool { + a.name == b.name + && a.value == b.value + && a.location == b.location + && a.backupLocation == b.backupLocation + && a.label == b.label + && a.decorative == b.decorative + } + } +} 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/Tests/OpenSwiftUICoreTests/View/Image/NamedImageTests.swift b/Tests/OpenSwiftUICoreTests/View/Image/NamedImageTests.swift new file mode 100644 index 000000000..d4a87e12d --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/View/Image/NamedImageTests.swift @@ -0,0 +1,413 @@ +// +// 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) + } +} From fbfaf35eb506099e178a2e5621ec64aa74cf7588 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 28 Feb 2026 00:37:32 +0800 Subject: [PATCH 02/19] Add ArchivedView --- .../View/Archive/ArchivedView.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 Sources/OpenSwiftUICore/View/Archive/ArchivedView.swift 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 {} From 66db78e85ce105ff3652e1aec552bdcc7b4a7676 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 28 Feb 2026 00:42:08 +0800 Subject: [PATCH 03/19] Update NamedImage --- .../View/Image/ImageScale.swift | 125 ----------- .../View/Image/NamedImage.swift | 196 +++++++++++++++--- 2 files changed, 167 insertions(+), 154 deletions(-) delete mode 100644 Sources/OpenSwiftUICore/View/Image/ImageScale.swift diff --git a/Sources/OpenSwiftUICore/View/Image/ImageScale.swift b/Sources/OpenSwiftUICore/View/Image/ImageScale.swift deleted file mode 100644 index e61202c9e..000000000 --- a/Sources/OpenSwiftUICore/View/Image/ImageScale.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// ImageScale.swift -// OpenSwiftUICore -// -// Audited for 6.5.4 -// Status: Complete -// ID: 8E7DCD4CEB1ACDE07B249BFF4CBC75C0 (SwiftUICore) -#if canImport(Darwin) -package import Foundation -#endif - -extension Image { - /// A hashable representation of `Image.Scale` for use as a cache key. - package enum HashableScale: Hashable { - case small - case medium - case large - case ccSmall - case ccMedium - case ccLarge - - 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 - default: self = .medium - } - } - - // MARK: - Helpers for symbol sizing - - /// 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" - package 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 - precondition(lower <= upper, "xx") - return lower ... upper - #else - return 0.0 ... .greatestFiniteMagnitude - #endif - } - } - - // Weight interpolation constants per scale category: - // (lightValue, nominalValue, heavyValue) - // where 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. - private static let smallConstants: (light: Double, nominal: Double, heavy: Double) = (74.46, 78.86, 83.98) - private static let mediumConstants: (light: Double, nominal: Double, heavy: Double) = (94.63, 99.61, 106.64) - private static let largeConstants: (light: Double, nominal: Double, heavy: Double) = (121.66, 127.2, 135.89) - - /// 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). - package func circleDotFillSize(pointSize: CGFloat, weight: Font.Weight) -> CGFloat { - let w = weight.value - let constants: (light: Double, nominal: Double, heavy: Double) - - // Discriminator bitmask: medium/ccMedium = 0x52, small/ccSmall = 0x9 - switch self { - case .medium, .ccMedium: - constants = Self.mediumConstants - case .small, .ccSmall: - constants = Self.smallConstants - default: // large, ccLarge - constants = Self.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). - package 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 - } - - return radius - } - } -} diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index c0f39e395..be3e662f7 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -3,16 +3,18 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: Complete +// Status: WIP // ID: 8E7DCD4CEB1ACDE07B249BFF4CBC75C0 (SwiftUICore) + package import Foundation package import OpenCoreGraphicsShims - #if OPENSWIFTUI_LINK_COREUI package import CoreUI #endif +#if OPENSWIFTUI_LINK_COREUI + // MARK: - NamedImage package enum NamedImage { @@ -72,8 +74,6 @@ package enum NamedImage { self.idiom = idiom } - #if OPENSWIFTUI_LINK_COREUI - // TODO: loadVectorInfo // [TBA] @@ -152,20 +152,19 @@ package enum NamedImage { let ratio = referenceRadius / actualRadius return min(range.upperBound, max(range.lowerBound, ratio)) } - #endif } - #if OPENSWIFTUI_LINK_COREUI // MARK: - VectorInfo - package struct VectorInfo { - package var glyph: CUINamedVectorGlyph + private struct VectorInfo { + var glyph: CUINamedVectorGlyph + + var flipsRightToLeft: Bool - package var flipsRightToLeft: Bool + var layoutMetrics: Image.LayoutMetrics - package var layoutMetrics: Image.LayoutMetrics + weak var catalog: CUICatalog? } - #endif // MARK: - NamedImage.BitmapKey [WIP] @@ -311,33 +310,36 @@ package enum NamedImage { } - // MARK: - NamedImage.Cache [TODO] + // MARK: - NamedImage.Cache package struct Cache { private struct ImageCacheData { + private var vectors: [NamedImage.VectorKey: NamedImage.VectorInfo] = [:] var bitmaps: [NamedImage.BitmapKey: NamedImage.BitmapInfo] = [:] var uuids: [UUID: NamedImage.DecodedInfo] = [:] - #if canImport(Darwin) && OPENSWIFTUI_LINK_COREUI var catalogs: [URL: WeakCatalog] = [:] - #endif } - #if canImport(Darwin) && OPENSWIFTUI_LINK_COREUI - struct WeakCatalog { + private struct WeakCatalog { weak var catalog: CUICatalog? } - #endif + + package var archiveDelegate: AnyArchivedViewDelegate? @AtomicBox - private var _data: ImageCacheData + private var data: ImageCacheData = .init() - package init() { - self.__data = AtomicBox(wrappedValue: ImageCacheData()) + package init(archiveDelegate: AnyArchivedViewDelegate? = nil) { + self.archiveDelegate = archiveDelegate } // MARK: Cache subscripts - #if canImport(Darwin) && OPENSWIFTUI_LINK_COREUI +// package subscript(key: VectorKey, catalog: CUICatalog) -> VectorInfo? { +// // TODO: Full CoreUI vector lookup implementation +// get { nil } +// } + package subscript(key: BitmapKey, location: Image.Location) -> BitmapInfo? { // TODO: Full CoreUI bitmap lookup implementation get { nil } @@ -347,7 +349,6 @@ package enum NamedImage { // TODO: Full CoreUI catalog lookup implementation get { nil } } - #endif package func decode(_ key: Key) throws -> DecodedInfo { switch key { @@ -361,11 +362,7 @@ package enum NamedImage { // MARK: - NamedImage.sharedCache - private static let _sharedCache = Cache() - - package static var sharedCache: Cache { - _sharedCache - } + package static var sharedCache = Cache() } // TODO: WIP @@ -385,7 +382,6 @@ extension Image { return true } - #if canImport(Darwin) && OPENSWIFTUI_LINK_COREUI package var catalog: CUICatalog? { switch self { case .bundle(let bundle): @@ -396,7 +392,6 @@ extension Image { return nil } } - #endif package var bundle: Bundle? { guard case .bundle(let bundle) = self else { @@ -564,3 +559,146 @@ extension Image.Location: ProtobufMessage { } } } + +#endif + +// MARK: - Image.HashableScale [WIP] + +extension Image { + /// A hashable representation of `Image.Scale` for use as a cache key. + package enum HashableScale: Hashable { + case small + case medium + case large + case ccSmall + case ccMedium + case ccLarge + + 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 + default: self = .medium + } + } + + // MARK: - Helpers for symbol sizing + + /// 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" + package 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 + precondition(lower <= upper, "xx") + return lower ... upper + #else + return 0.0 ... .greatestFiniteMagnitude + #endif + } + } + + // Weight interpolation constants per scale category: + // (lightValue, nominalValue, heavyValue) + // where 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. + private static let smallConstants: (light: Double, nominal: Double, heavy: Double) = (74.46, 78.86, 83.98) + private static let mediumConstants: (light: Double, nominal: Double, heavy: Double) = (94.63, 99.61, 106.64) + private static let largeConstants: (light: Double, nominal: Double, heavy: Double) = (121.66, 127.2, 135.89) + + /// 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). + package func circleDotFillSize(pointSize: CGFloat, weight: Font.Weight) -> CGFloat { + let w = weight.value + let constants: (light: Double, nominal: Double, heavy: Double) + + // Discriminator bitmask: medium/ccMedium = 0x52, small/ccSmall = 0x9 + switch self { + case .medium, .ccMedium: + constants = Self.mediumConstants + case .small, .ccSmall: + constants = Self.smallConstants + default: // large, ccLarge + constants = Self.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). + package 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 + } + + return radius + } + } +} + + +// MARK: - Image.ResolvedUUID [WIP] + +@_spi(Private) +@available(OpenSwiftUI_v4_0, *) +extension Image { + + public struct ResolvedUUID { + package var cgImage: CGImage + package var scale: CGFloat + package 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 {} From 5b96e4cf048fb085897b2a097e3d3e347f604fef Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 28 Feb 2026 00:51:41 +0800 Subject: [PATCH 04/19] Update Image+ImageResource --- .../View/Image/NamedImage.swift | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index be3e662f7..d1d64d5b9 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -6,15 +6,12 @@ // Status: WIP // ID: 8E7DCD4CEB1ACDE07B249BFF4CBC75C0 (SwiftUICore) - -package import Foundation +public import Foundation package import OpenCoreGraphicsShims #if OPENSWIFTUI_LINK_COREUI package import CoreUI #endif -#if OPENSWIFTUI_LINK_COREUI - // MARK: - NamedImage package enum NamedImage { @@ -74,6 +71,7 @@ package enum NamedImage { self.idiom = idiom } + #if OPENSWIFTUI_LINK_COREUI // TODO: loadVectorInfo // [TBA] @@ -152,11 +150,14 @@ package enum NamedImage { let ratio = referenceRadius / actualRadius return min(range.upperBound, max(range.lowerBound, ratio)) } + #endif } + // MARK: - VectorInfo private struct VectorInfo { + #if OPENSWIFTUI_LINK_COREUI var glyph: CUINamedVectorGlyph var flipsRightToLeft: Bool @@ -164,8 +165,10 @@ package enum NamedImage { var layoutMetrics: Image.LayoutMetrics weak var catalog: CUICatalog? + #endif } + // MARK: - NamedImage.BitmapKey [WIP] package struct BitmapKey: Hashable { @@ -321,7 +324,9 @@ package enum NamedImage { } private struct WeakCatalog { + #if OPENSWIFTUI_LINK_COREUI weak var catalog: CUICatalog? + #endif } package var archiveDelegate: AnyArchivedViewDelegate? @@ -335,6 +340,8 @@ package enum NamedImage { // MARK: Cache subscripts + #if OPENSWIFTUI_LINK_COREUI + // package subscript(key: VectorKey, catalog: CUICatalog) -> VectorInfo? { // // TODO: Full CoreUI vector lookup implementation // get { nil } @@ -350,6 +357,8 @@ package enum NamedImage { get { nil } } + #endif + package func decode(_ key: Key) throws -> DecodedInfo { switch key { case .bitmap: @@ -365,9 +374,12 @@ package enum NamedImage { package static var sharedCache = Cache() } -// TODO: WIP +@available(OpenSwiftUI_v1_0, *) +extension Image { + public static var _mainNamedBundle: Bundle? { nil } +} -// MARK: - Image.Location +// MARK: - Image.Location [WIP] extension Image { package enum Location: Equatable, Hashable { @@ -560,8 +572,6 @@ extension Image.Location: ProtobufMessage { } } -#endif - // MARK: - Image.HashableScale [WIP] extension Image { @@ -679,7 +689,6 @@ extension Image { } } - // MARK: - Image.ResolvedUUID [WIP] @_spi(Private) @@ -702,3 +711,17 @@ extension Image { @_spi(Private) @available(*, unavailable) extension Image.ResolvedUUID: Sendable {} + +#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 From 183e4836a2b05d1cd6387ee25306421239350e48 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 1 Mar 2026 17:45:56 +0800 Subject: [PATCH 05/19] Update ResolvedImage --- .../Graphic/Color/NamedColor.swift | 2 +- .../View/Image/NamedImage.swift | 336 +++++++++++++++++- .../View/Image/ResolvedImage.swift | 44 ++- .../Shims/CoreGraphics/CoreGraphics_Private.h | 3 + 4 files changed, 367 insertions(+), 18 deletions(-) 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/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index d1d64d5b9..172cfb968 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -8,6 +8,9 @@ public import Foundation package import OpenCoreGraphicsShims +#if canImport(CoreGraphics) +import CoreGraphics_Private +#endif #if OPENSWIFTUI_LINK_COREUI package import CoreUI #endif @@ -72,7 +75,65 @@ package enum NamedImage { } #if OPENSWIFTUI_LINK_COREUI - // TODO: loadVectorInfo + 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 glyphWt = self.weight.glyphWeight + guard var result = catalog.namedVectorGlyph( + withName: self.name, + scaleFactor: self.scale, + deviceIdiom: cuiIdiom, + layoutDirection: cuiLayoutDir, + glyphSize: glyphWt, + 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 + ) + } // [TBA] /// Computes a scale factor for symbol images based on the glyph's @@ -156,7 +217,7 @@ package enum NamedImage { // MARK: - VectorInfo - private struct VectorInfo { + fileprivate struct VectorInfo { #if OPENSWIFTUI_LINK_COREUI var glyph: CUINamedVectorGlyph @@ -251,7 +312,118 @@ package enum NamedImage { } } - // TODO: loadBitmapInfo + #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 @@ -317,7 +489,7 @@ package enum NamedImage { package struct Cache { private struct ImageCacheData { - private var vectors: [NamedImage.VectorKey: NamedImage.VectorInfo] = [:] + var vectors: [NamedImage.VectorKey: NamedImage.VectorInfo] = [:] var bitmaps: [NamedImage.BitmapKey: NamedImage.BitmapInfo] = [:] var uuids: [UUID: NamedImage.DecodedInfo] = [:] var catalogs: [URL: WeakCatalog] = [:] @@ -342,29 +514,97 @@ package enum NamedImage { #if OPENSWIFTUI_LINK_COREUI -// package subscript(key: VectorKey, catalog: CUICatalog) -> VectorInfo? { -// // TODO: Full CoreUI vector lookup implementation -// get { nil } -// } + // Looks up cached VectorInfo for key; if not found or catalog changed, + // calls loadVectorInfo and caches the result. + private subscript(key: VectorKey, catalog: CUICatalog) -> VectorInfo? { + get { + 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 + } + } + // Looks up cached BitmapInfo for key; if not found, + // calls loadBitmapInfo and caches the result. package subscript(key: BitmapKey, location: Image.Location) -> BitmapInfo? { - // TODO: Full CoreUI bitmap lookup implementation - get { nil } + get { + 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 + } } + // 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)? { - // TODO: Full CoreUI catalog lookup implementation - get { nil } + get { + 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: + case .bitmap(let bitmapKey): + #if OPENSWIFTUI_LINK_COREUI + if let info = self[bitmapKey, bitmapKey.location] { + return DecodedInfo( + contents: info.contents, + scale: info.scale, + unrotatedPixelSize: info.unrotatedPixelSize, + orientation: info.orientation + ) + } + #endif throw Errors.missingCatalogImage - case .uuid: - throw Errors.missingUUIDImage + 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 } } } @@ -712,6 +952,72 @@ extension Image { @available(*, unavailable) extension Image.ResolvedUUID: Sendable {} +// MARK: - CUI Helpers + +#if OPENSWIFTUI_LINK_COREUI + +extension Font.Weight { + /// Maps Font.Weight to CUI's _CUIThemeVectorGlyphWeight integer 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: Int { + let v = value + let tolerance = 0.001 + if abs(v - (-0.8)) < tolerance { return 1 } + if abs(v - (-0.6)) < tolerance { return 2 } + if abs(v - (-0.4)) < tolerance { return 3 } + if abs(v - 0.0) < tolerance { return 4 } + if abs(v - 0.23) < tolerance { return 5 } + if abs(v - 0.3) < tolerance { return 6 } + if abs(v - 0.4) < tolerance { return 7 } + if abs(v - 0.56) < tolerance { return 8 } + if abs(v - 0.62) < tolerance { return 9 } + return 4 + } + + /// Maps Font.Weight to CUI's continuous weight CGFloat value. + /// + /// Looks up the integer glyphWeight, then returns the corresponding + /// `_CUIVectorGlyphContinuousWeight*` constant from CoreUI. + fileprivate var glyphContinuousWeight: CGFloat { + let w = glyphWeight + switch w { + case 1: return _CUIVectorGlyphContinuousWeightUltralight + case 2: return _CUIVectorGlyphContinuousWeightThin + case 3: return _CUIVectorGlyphContinuousWeightLight + case 4: return _CUIVectorGlyphContinuousWeightRegular + case 5: return _CUIVectorGlyphContinuousWeightMedium + case 6: return _CUIVectorGlyphContinuousWeightSemibold + case 7: return _CUIVectorGlyphContinuousWeightBold + case 8: return _CUIVectorGlyphContinuousWeightHeavy + case 9: return _CUIVectorGlyphContinuousWeightBlack + 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 diff --git a/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift b/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift index 7a082afb7..d5b4bbbbf 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,44 @@ extension Image { self.backgroundSize = .zero } - // TODO: CUINamedVectorGlyph + #if OPENSWIFTUI_LINK_COREUI + 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/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 */ From 4f8933cae2dbf251076a78946bc9e4738547e662 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 1 Mar 2026 17:59:00 +0800 Subject: [PATCH 06/19] Update CUI type usage in NamedImage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use _CUIThemeVectorGlyphWeight enum for Font.Weight.glyphWeight - Add Image.HashableScale.glyphSize mapping (small→1, medium→2, large→3) - Fix namedVectorGlyph call to pass glyphSize from imageScale instead of glyphWeight - Pass Locale directly to namedVectorGlyph locale parameter (NSLocale bridging) - Force unwrap CUIDeviceIdiom(rawValue:) instead of fallback to .universal --- Package.resolved | 51 +--------------- .../View/Image/NamedImage.swift | 61 +++++++++++-------- .../View/Image/ResolvedImage.swift | 1 - 3 files changed, 38 insertions(+), 75 deletions(-) diff --git a/Package.resolved b/Package.resolved index 6854d8817..a622e22c9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,58 +1,13 @@ { "originHash" : "1a1b5ecad37792a3e2709ae10deec8d8827ca2bb901071248a9da60807c037f2", "pins" : [ - { - "identity" : "darwinprivateframeworks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OpenSwiftUIProject/DarwinPrivateFrameworks.git", - "state" : { - "branch" : "main", - "revision" : "18d26f5dcc334468fad0904697233faeb41a1805" - } - }, - { - "identity" : "openattributegraph", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OpenSwiftUIProject/OpenAttributeGraph", - "state" : { - "branch" : "main", - "revision" : "2682d4b34acd8a1482dd62e66e084f346d6ca310" - } - }, - { - "identity" : "opencoregraphics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OpenSwiftUIProject/OpenCoreGraphics", - "state" : { - "branch" : "main", - "revision" : "8d63c405f565fb287042e0b8dc3bf0c4b8b2b56f" - } - }, - { - "identity" : "openobservation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OpenSwiftUIProject/OpenObservation", - "state" : { - "branch" : "main", - "revision" : "814dbe008056db6007bfc3d27fe585837f30e9ed" - } - }, - { - "identity" : "openrenderbox", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OpenSwiftUIProject/OpenRenderBox", - "state" : { - "branch" : "main", - "revision" : "ba06b06d03c0e25922ebc2fee183f62a39fdd8d4" - } - }, { "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/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index 172cfb968..ae8a24b83 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -83,13 +83,14 @@ package enum NamedImage { ) { 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: glyphWt, + glyphSize: glyphSz, glyphWeight: glyphWt, glyphPointSize: self.pointSize, appearanceName: appearanceName, @@ -838,6 +839,15 @@ extension Image { // MARK: - Helpers for symbol sizing + /// 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: 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) @@ -957,7 +967,7 @@ extension Image.ResolvedUUID: Sendable {} #if OPENSWIFTUI_LINK_COREUI extension Font.Weight { - /// Maps Font.Weight to CUI's _CUIThemeVectorGlyphWeight integer values. + /// Maps Font.Weight to CUI's `_CUIThemeVectorGlyphWeight` values. /// /// Matches each known weight value (within 0.001 tolerance): /// - ultraLight (-0.8) → 1 @@ -970,38 +980,37 @@ extension Font.Weight { /// - heavy (0.56) → 8 /// - black (0.62) → 9 /// - unknown → 4 (regular) - fileprivate var glyphWeight: Int { + fileprivate var glyphWeight: _CUIThemeVectorGlyphWeight { let v = value let tolerance = 0.001 - if abs(v - (-0.8)) < tolerance { return 1 } - if abs(v - (-0.6)) < tolerance { return 2 } - if abs(v - (-0.4)) < tolerance { return 3 } - if abs(v - 0.0) < tolerance { return 4 } - if abs(v - 0.23) < tolerance { return 5 } - if abs(v - 0.3) < tolerance { return 6 } - if abs(v - 0.4) < tolerance { return 7 } - if abs(v - 0.56) < tolerance { return 8 } - if abs(v - 0.62) < tolerance { return 9 } - return 4 + 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 integer glyphWeight, then returns the corresponding + /// Looks up the `_CUIThemeVectorGlyphWeight`, then returns the corresponding /// `_CUIVectorGlyphContinuousWeight*` constant from CoreUI. fileprivate var glyphContinuousWeight: CGFloat { - let w = glyphWeight - switch w { - case 1: return _CUIVectorGlyphContinuousWeightUltralight - case 2: return _CUIVectorGlyphContinuousWeightThin - case 3: return _CUIVectorGlyphContinuousWeightLight - case 4: return _CUIVectorGlyphContinuousWeightRegular - case 5: return _CUIVectorGlyphContinuousWeightMedium - case 6: return _CUIVectorGlyphContinuousWeightSemibold - case 7: return _CUIVectorGlyphContinuousWeightBold - case 8: return _CUIVectorGlyphContinuousWeightHeavy - case 9: return _CUIVectorGlyphContinuousWeightBlack - default: return _CUIVectorGlyphContinuousWeightRegular + 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 } } } diff --git a/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift b/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift index d5b4bbbbf..28b925caa 100644 --- a/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift @@ -68,7 +68,6 @@ extension Image { contentSize = CGSize(width: alignmentRect.width, height: capHeight) alignmentOrigin = CGPoint(x: 0, y: -baselineOffset) } - self.init( baselineOffset: baselineOffset, capHeight: capHeight, From f52d0c5481639dfcca9116cb47ebc65f6b904f77 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 1 Mar 2026 18:05:02 +0800 Subject: [PATCH 07/19] Update symbolSizeScale --- Sources/OpenSwiftUICore/View/Image/NamedImage.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index ae8a24b83..c660e711c 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -19,7 +19,7 @@ package import CoreUI package enum NamedImage { - // MARK: - NamedImage.VectorKey [WIP] + // MARK: - NamedImage.VectorKey package struct VectorKey: Hashable { package var catalogKey: CatalogKey @@ -136,7 +136,6 @@ package enum NamedImage { ) } - // [TBA] /// Computes a scale factor for symbol images based on the glyph's /// actual path radius relative to a reference circle.fill size. /// @@ -149,10 +148,10 @@ package enum NamedImage { private func symbolSizeScale(for glyph: CUINamedVectorGlyph) -> CGFloat { let range = imageScale.allowedScaleRange guard range.lowerBound < range.upperBound else { - return 1.0 + return range.lowerBound } - guard let layers = glyph.monochromeLayers as? [CUIVectorGlyphLayer] else { + guard let layers = glyph.monochromeLayers else { return 1.0 } @@ -173,6 +172,7 @@ package enum NamedImage { 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 From 21f625e1975d2bd6c0c98f2eae840a9b65fbdf6f Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 1 Mar 2026 18:15:48 +0800 Subject: [PATCH 08/19] Clean up NamedImage.Cache subscript structure - Remove explicit get blocks from computed subscripts - Move #if OPENSWIFTUI_LINK_COREUI guards to cover individual subscripts - Add platform unimplemented warning for BitmapKey subscript on non-CoreUI - Use guard let in decode(_:) for cleaner control flow --- .../View/Image/NamedImage.swift | 93 +++++++++---------- 1 file changed, 45 insertions(+), 48 deletions(-) diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index c660e711c..a473b68d3 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -483,7 +483,6 @@ package enum NamedImage { package enum Errors: Error, Equatable, Hashable { case missingCatalogImage case missingUUIDImage - } // MARK: - NamedImage.Cache @@ -514,61 +513,61 @@ package enum NamedImage { // 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. private subscript(key: VectorKey, catalog: CUICatalog) -> VectorInfo? { - get { - 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 + let cached = data.vectors[key] + if let cached { + if let cachedCatalog = cached.catalog, cachedCatalog == catalog { + return cached } - data.vectors[key] = info - return info } + 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? { - get { - 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 + #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)? { - get { - 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) + 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 @@ -576,17 +575,15 @@ package enum NamedImage { package func decode(_ key: Key) throws -> DecodedInfo { switch key { case .bitmap(let bitmapKey): - #if OPENSWIFTUI_LINK_COREUI - if let info = self[bitmapKey, bitmapKey.location] { - return DecodedInfo( - contents: info.contents, - scale: info.scale, - unrotatedPixelSize: info.unrotatedPixelSize, - orientation: info.orientation - ) + guard let info = self[bitmapKey, bitmapKey.location] else { + throw Errors.missingCatalogImage } - #endif - 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 From e507ab0b8b8eacb9c3af7e60c2e584fe662db187 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 1 Mar 2026 18:20:31 +0800 Subject: [PATCH 09/19] Add missing Image.Location members - Add fillVariant(_:name:) for fill symbol variant lookup - Add mayContainSymbol(_:) for checking if catalog contains a symbol - Add findShapeAndFillVariantName(_:base:body:) for shape+fill variant resolution - Add findName(_:base:body:) for full variant name resolution - Add SystemAssetManager struct with systemAssetManager/privateSystemAssetManager statics --- .../View/Image/NamedImage.swift | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index a473b68d3..badb67141 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -650,6 +650,46 @@ extension Image { return bundle } + package func fillVariant(_ variants: SymbolVariants, name: String) -> String? { + guard variants.contains(.fill) else { return nil } + let fillName = name + ".fill" + guard let catalog else { return nil } + return catalog.imageExists(withName: fillName) ? fillName : nil + } + + package func mayContainSymbol(_ name: String) -> Bool { + guard let catalog else { return false } + return catalog.containsLookup(forName: name) + } + + package func findShapeAndFillVariantName(_ variants: SymbolVariants, base: String, body: (String) -> T?) -> T? { + if let shapeName = variants.shapeVariantName(name: base) { + if let fillName = fillVariant(variants, name: shapeName) { + if let result = body(fillName) { return result } + } + if let result = body(shapeName) { return result } + } + if let fillName = fillVariant(variants, name: base) { + if let result = body(fillName) { return result } + } + return nil + } + + package func findName(_ variants: SymbolVariants, base: String, body: (String) -> T?) -> T? { + if let result = findShapeAndFillVariantName(variants, base: base, body: body) { + return result + } + return body(base) + } + + package static let systemAssetManager = SystemAssetManager(location: .system) + + package static let privateSystemAssetManager = SystemAssetManager(location: .privateSystem) + + package struct SystemAssetManager { + var location: Image.Location + } + package static func == (a: Image.Location, b: Image.Location) -> Bool { switch (a, b) { case let (.bundle(lhs), .bundle(rhs)): From f918c3fbc332c45f39209776dc01e623e5f0d5f1 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 1 Mar 2026 19:18:44 +0800 Subject: [PATCH 10/19] Fix Image.Location implementation based on Hopper analysis - SystemAssetManager: proper struct with catalog, fillMapping, nameAliases, symbols fields; init(internalUse:) loads from CoreGlyphs bundles - catalog getter: returns SystemAssetManager.catalog for system locations - fillVariant: uses fillMapping dict for system, name+".fill" for bundle - mayContainSymbol: uses symbols array for system, true for bundle - Add aliasedName() using nameAliases dictionary - findShapeAndFillVariantName: calls aliasedName() before lookups - findName: handles .slash variant, normalizes .background semantics - Add _SimulatorSystemRootDirectory() wrapper for GSSystemRootDirectory - Add GraphicsServices_Private SPI module --- Package.resolved | 45 ++++++ .../View/Image/NamedImage.swift | 128 ++++++++++++------ .../View/Image/SymbolVariants.swift | 12 ++ .../Shims/GraphicsServices/GraphicsServices.h | 22 +++ .../Shims/GraphicsServices/GraphicsServices.m | 23 ++++ Sources/OpenSwiftUI_SPI/module.modulemap | 5 + 6 files changed, 197 insertions(+), 38 deletions(-) create mode 100644 Sources/OpenSwiftUI_SPI/Shims/GraphicsServices/GraphicsServices.h create mode 100644 Sources/OpenSwiftUI_SPI/Shims/GraphicsServices/GraphicsServices.m diff --git a/Package.resolved b/Package.resolved index a622e22c9..1f5562c6e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,51 @@ { "originHash" : "1a1b5ecad37792a3e2709ae10deec8d8827ca2bb901071248a9da60807c037f2", "pins" : [ + { + "identity" : "darwinprivateframeworks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenSwiftUIProject/DarwinPrivateFrameworks.git", + "state" : { + "branch" : "main", + "revision" : "18d26f5dcc334468fad0904697233faeb41a1805" + } + }, + { + "identity" : "openattributegraph", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenSwiftUIProject/OpenAttributeGraph", + "state" : { + "branch" : "main", + "revision" : "91dd6f1b3b3bcfc13872b0e7eb145302279ebf04" + } + }, + { + "identity" : "opencoregraphics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenSwiftUIProject/OpenCoreGraphics", + "state" : { + "branch" : "main", + "revision" : "8d63c405f565fb287042e0b8dc3bf0c4b8b2b56f" + } + }, + { + "identity" : "openobservation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenSwiftUIProject/OpenObservation", + "state" : { + "branch" : "main", + "revision" : "814dbe008056db6007bfc3d27fe585837f30e9ed" + } + }, + { + "identity" : "openrenderbox", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenSwiftUIProject/OpenRenderBox", + "state" : { + "branch" : "main", + "revision" : "ba06b06d03c0e25922ebc2fee183f62a39fdd8d4" + } + }, { "identity" : "swift-numerics", "kind" : "remoteSourceControl", diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index badb67141..75798d73d 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -652,68 +652,120 @@ extension Image { package func fillVariant(_ variants: SymbolVariants, name: String) -> String? { guard variants.contains(.fill) else { return nil } - let fillName = name + ".fill" - guard let catalog else { return nil } - return catalog.imageExists(withName: fillName) ? fillName : nil + switch self { + #if OPENSWIFTUI_LINK_COREUI + case .system: + return Self.systemAssetManager.fillMapping[name] + case .privateSystem: + return Self.privateSystemAssetManager.fillMapping[name] + #else + case .system, .privateSystem: + return nil + #endif + case .bundle: + return name + ".fill" + } } package func mayContainSymbol(_ name: String) -> Bool { - guard let catalog else { return false } - return catalog.containsLookup(forName: name) + switch self { + #if OPENSWIFTUI_LINK_COREUI + case .system: + return Self.systemAssetManager.symbols.contains(name) + case .privateSystem: + return Self.privateSystemAssetManager.symbols.contains(name) + #else + case .system, .privateSystem: + return false + #endif + case .bundle: + return true + } + } + + private func aliasedName(_ name: String) -> String { + switch self { + #if OPENSWIFTUI_LINK_COREUI + case .system: + return Self.systemAssetManager.nameAliases[name] ?? name + case .privateSystem: + return Self.privateSystemAssetManager.nameAliases[name] ?? name + #else + case .system, .privateSystem: + return name + #endif + case .bundle: + return name + } } package func findShapeAndFillVariantName(_ variants: SymbolVariants, base: String, body: (String) -> T?) -> T? { if let shapeName = variants.shapeVariantName(name: base) { - if let fillName = fillVariant(variants, name: shapeName) { + let aliasedShape = aliasedName(shapeName) + if let fillName = fillVariant(variants, name: aliasedShape) { if let result = body(fillName) { return result } } - if let result = body(shapeName) { return result } + if let result = body(aliasedShape) { return result } } - if let fillName = fillVariant(variants, name: base) { + let aliasedBase = aliasedName(base) + if let fillName = fillVariant(variants, name: aliasedBase) { if let result = body(fillName) { return result } } - return nil + return body(aliasedBase) } package func findName(_ variants: SymbolVariants, base: String, body: (String) -> T?) -> T? { - if let result = findShapeAndFillVariantName(variants, base: base, body: body) { - return result + 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) } - return body(base) } - package static let systemAssetManager = SystemAssetManager(location: .system) + #if OPENSWIFTUI_LINK_COREUI + package static let systemAssetManager = SystemAssetManager(internalUse: false) - package static let privateSystemAssetManager = SystemAssetManager(location: .privateSystem) + package static let privateSystemAssetManager = SystemAssetManager(internalUse: true) package struct SystemAssetManager { - var location: Image.Location - } - - package static func == (a: Image.Location, b: Image.Location) -> Bool { - switch (a, b) { - case let (.bundle(lhs), .bundle(rhs)): - return lhs == rhs - case (.system, .system): - return true - case (.privateSystem, .privateSystem): - return true - default: - return false - } - } + let catalog: CUICatalog + let fillMapping: [String: String] + let nameAliases: [String: String] + let symbols: [String] + + package init(internalUse: Bool) { + let bundlePath: String + if internalUse { + // TODO: Load from SFSymbols private framework + // fillMapping = SFSymbols.private_nofill_to_fill + // nameAliases = SFSymbols.private_name_aliases + // symbols = SFSymbols.private_symbol_order + fillMapping = [:] + nameAliases = [:] + symbols = [] + bundlePath = "/System/Library/CoreServices/CoreGlyphsPrivate.bundle" + } else { + // TODO: Load from SFSymbols framework + // fillMapping = SFSymbols.nofill_to_fill + // nameAliases = SFSymbols.name_aliases + // symbols = SFSymbols.symbol_order + fillMapping = [:] + nameAliases = [:] + symbols = [] + bundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle" + } - package func hash(into hasher: inout Hasher) { - switch self { - case .bundle(let bundle): - hasher.combine(0) - hasher.combine(bundle.bundleURL) - case .system: - hasher.combine(1) - case .privateSystem: - hasher.combine(2) + let fullPath = _SimulatorSystemRootDirectory() + bundlePath + let bundle = Bundle(path: fullPath)! + catalog = try! CUICatalog(name: "Assets", from: bundle, error: ()) } } + #endif } } diff --git a/Sources/OpenSwiftUICore/View/Image/SymbolVariants.swift b/Sources/OpenSwiftUICore/View/Image/SymbolVariants.swift index b731ce4cb..37e6b8dd9 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. /// 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 * From 71e0aeb3e755dca4065261afa325f1bb04987b36 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 1 Mar 2026 19:20:43 +0800 Subject: [PATCH 11/19] Refine SystemAssetManager for cross-platform availability Move SystemAssetManager and its static instances outside of #if OPENSWIFTUI_LINK_COREUI, keeping only the CUICatalog field and init logic conditional. --- .../View/Image/NamedImage.swift | 35 +++++++------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index 75798d73d..c146efb63 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -13,6 +13,7 @@ import CoreGraphics_Private #endif #if OPENSWIFTUI_LINK_COREUI package import CoreUI +import GraphicsServices_Private #endif // MARK: - NamedImage @@ -617,7 +618,7 @@ extension Image { public static var _mainNamedBundle: Bundle? { nil } } -// MARK: - Image.Location [WIP] +// MARK: - Image.Location extension Image { package enum Location: Equatable, Hashable { @@ -632,16 +633,18 @@ extension Image { return true } + #if OPENSWIFTUI_LINK_COREUI package var catalog: CUICatalog? { switch self { - case .bundle(let bundle): - return NamedImage.sharedCache[bundle]?.0 case .system: - return nil + return Self.systemAssetManager.catalog case .privateSystem: - return nil + 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 { @@ -653,15 +656,10 @@ extension Image { package func fillVariant(_ variants: SymbolVariants, name: String) -> String? { guard variants.contains(.fill) else { return nil } switch self { - #if OPENSWIFTUI_LINK_COREUI case .system: return Self.systemAssetManager.fillMapping[name] case .privateSystem: return Self.privateSystemAssetManager.fillMapping[name] - #else - case .system, .privateSystem: - return nil - #endif case .bundle: return name + ".fill" } @@ -669,15 +667,10 @@ extension Image { package func mayContainSymbol(_ name: String) -> Bool { switch self { - #if OPENSWIFTUI_LINK_COREUI case .system: return Self.systemAssetManager.symbols.contains(name) case .privateSystem: return Self.privateSystemAssetManager.symbols.contains(name) - #else - case .system, .privateSystem: - return false - #endif case .bundle: return true } @@ -685,15 +678,10 @@ extension Image { private func aliasedName(_ name: String) -> String { switch self { - #if OPENSWIFTUI_LINK_COREUI case .system: return Self.systemAssetManager.nameAliases[name] ?? name case .privateSystem: return Self.privateSystemAssetManager.nameAliases[name] ?? name - #else - case .system, .privateSystem: - return name - #endif case .bundle: return name } @@ -727,13 +715,14 @@ extension Image { } } - #if OPENSWIFTUI_LINK_COREUI 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] @@ -759,13 +748,13 @@ extension Image { symbols = [] 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 } } - #endif } } From 0690f5de30443f7071503a2c698083551727d268 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 1 Mar 2026 19:41:33 +0800 Subject: [PATCH 12/19] Update NamedImageProvider --- .../View/Image/Image+NamedImage.swift | 228 -------- .../View/Image/NamedImage.swift | 490 +++++++++++++++--- .../View/Image/NamedImageProvider.swift | 89 ---- 3 files changed, 412 insertions(+), 395 deletions(-) delete mode 100644 Sources/OpenSwiftUICore/View/Image/Image+NamedImage.swift delete mode 100644 Sources/OpenSwiftUICore/View/Image/NamedImageProvider.swift diff --git a/Sources/OpenSwiftUICore/View/Image/Image+NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/Image+NamedImage.swift deleted file mode 100644 index 0f32b3376..000000000 --- a/Sources/OpenSwiftUICore/View/Image/Image+NamedImage.swift +++ /dev/null @@ -1,228 +0,0 @@ -// -// Image+NamedImage.swift -// OpenSwiftUICore -// -// Audited for 6.5.4 -// Status: Complete -// ID: 8E7DCD4CEB1ACDE07B249BFF4CBC75C0 (SwiftUICore) - -public import Foundation - -// MARK: - Image named initializers - -@available(OpenSwiftUI_v1_0, *) -extension Image { - /// Creates a named image. - /// - /// Use this initializer to load an image stored in your app's asset - /// catalog by name. OpenSwiftUI treats the image as accessory-level by - /// default. - /// - /// Use the ``Image/init(_:bundle:label:)`` initializer instead if you - /// want to provide accessibility information about the image. - /// - /// - Parameters: - /// - name: The name of the image resource to look up. - /// - bundle: The bundle in which to search for the image resource. If - /// you don't indicate a bundle, the initializer looks in your app's - /// main bundle by default. - public init(_ name: String, bundle: Bundle? = nil) { - self.init( - NamedImageProvider( - name: name, - location: .bundle(bundle ?? Bundle.main), - label: AccessibilityImageLabel(name), - decorative: false - ) - ) - } - - /// Creates a labeled named image. - /// - /// Creates an image by looking for a named resource in the specified - /// bundle. The system uses the provided label text for accessibility. - /// - /// - Parameters: - /// - name: The name of the image resource to look up. - /// - bundle: The bundle in which to search for the image resource. - /// If you don't indicate a bundle, the initializer looks in your app's - /// main bundle by default. - /// - label: The label text to use 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 a decorative named image. - /// - /// Creates an image by looking for a named resource in the specified - /// bundle. The accessibility system ignores decorative images. - /// - /// - Parameters: - /// - name: The name of the image resource to look up. - /// - bundle: The bundle in which to search for the image resource. - /// If you don't indicate a bundle, the initializer looks in your app's - /// main bundle by default. - 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. - /// - /// Use this initializer to load an SF Symbols image by name. - /// - /// - Parameter systemName: The name of the system symbol image. - @available(macOS, introduced: 11.0) - public init(systemName: String) { - self.init( - NamedImageProvider( - name: systemName, - location: .system, - label: .systemSymbol(systemName), - decorative: false - ) - ) - } - - /// Creates a system symbol image for internal use. - /// - /// - Parameter systemName: The name of the system symbol image. - @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) - public init(_internalSystemName systemName: String) { - self.init( - NamedImageProvider( - name: systemName, - location: .system, - label: .systemSymbol(systemName), - decorative: false, - backupLocation: .privateSystem - ) - ) - } -} - -// MARK: - Image named initializers with variableValue - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension Image { - /// Creates a named image with a variable value. - /// - /// This initializer creates an image using a named image resource, with - /// an optional variable value that some symbol images use to customize - /// their appearance. - /// - /// - Parameters: - /// - name: The name of the image resource to look up. - /// - 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 colors, this - /// parameter has no effect. Use the SF Symbols app to look up which - /// symbols support variable colors. - /// - bundle: The bundle in which to search for the image resource. - /// If you don't indicate a bundle, the initializer looks in your app's - /// main bundle by default. - 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(name), - decorative: false - ) - ) - } - - /// Creates a labeled named image with a variable value. - /// - /// - Parameters: - /// - name: The name of the image resource to look up. - /// - variableValue: An optional value between `0.0` and `1.0` that - /// the rendered image can use to customize its appearance. - /// - bundle: The bundle in which to search for the image resource. - /// If you don't indicate a bundle, the initializer looks in your app's - /// main bundle by default. - /// - label: The label text to use 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 a decorative named image with a variable value. - /// - /// - Parameters: - /// - name: The name of the image resource to look up. - /// - variableValue: An optional value between `0.0` and `1.0` that - /// the rendered image can use to customize its appearance. - /// - bundle: The bundle in which to search for the image resource. - /// If you don't indicate a bundle, the initializer looks in your app's - /// main bundle by default. - 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 - ) - ) - } - - /// Creates a system symbol image with a variable value. - /// - /// - Parameters: - /// - systemName: The name of the system symbol image. - /// - variableValue: An optional value between `0.0` and `1.0` that - /// the rendered image can use to customize its appearance. - public init(systemName: String, variableValue: Double?) { - self.init( - NamedImageProvider( - name: systemName, - value: variableValue.map { Float($0) }, - location: .system, - label: .systemSymbol(systemName), - decorative: false - ) - ) - } - - /// Creates a system symbol image with a variable value for internal use. - /// - /// - Parameters: - /// - systemName: The name of the system symbol image. - /// - variableValue: An optional value between `0.0` and `1.0` that - /// the rendered image can use to customize its appearance. - public init(_internalSystemName systemName: String, variableValue: Double?) { - self.init( - NamedImageProvider( - name: systemName, - value: variableValue.map { Float($0) }, - location: .system, - label: .systemSymbol(systemName), - decorative: false, - backupLocation: .privateSystem - ) - ) - } -} diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index c146efb63..21e31f20e 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -620,6 +620,7 @@ extension Image { // MARK: - Image.Location +@available(OpenSwiftUI_v1_0, *) extension Image { package enum Location: Equatable, Hashable { case bundle(Bundle) @@ -756,98 +757,337 @@ extension Image { } } } -} -// MARK: - NamedImage.Key + ProtobufMessage + // MARK: - Image named initializers -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: - // TODO: UUID protobuf encoding - break - } + /// 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 + ) + ) } - 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)) - default: - try decoder.skipField(field) - } + /// 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 } - guard let result else { - throw ProtobufDecoder.DecodingError.failed + + package func resolve(in context: ImageResolutionContext) -> Image.Resolved { + // TODO: Full CoreUI-based resolution + // The real implementation: + // 1. Tries vector resolution first (via vectorInfo) + // 2. Falls back to bitmap resolution (via bitmapInfo) + // 3. Returns resolveError if both fail + resolveError(in: context.environment) + } + + package func resolveError(in environment: EnvironmentValues) -> Image.Resolved { + Image.Resolved( + image: GraphicsImage( + contents: nil, + scale: environment.displayScale, + unrotatedPixelSize: .zero, + orientation: .up, + isTemplate: false + ), + decorative: decorative, + label: label + ) + } + + package func resolveNamedImage(in context: ImageResolutionContext) -> Image.NamedResolved? { + let environment = context.environment + let isTemplate = environment.imageIsTemplate() + return Image.NamedResolved( + name: name, + location: location, + value: value, + symbolRenderingMode: context.symbolRenderingMode?.storage, + isTemplate: isTemplate, + environment: environment + ) } - self = result } } -// MARK: - NamedImage.BitmapKey + ProtobufMessage +// MARK: - Image named initializers with variableValue -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) - try encoder.messageField(4, location) - encoder.intField(5, layoutDirection == .rightToLeft ? 1 : 0) - // locale encoding omitted - Locale does not yet conform to ProtobufMessage - encoder.intField(7, gamut.rawValue) - encoder.intField(8, idiom) - encoder.intField(9, subtype) - encoder.intField(10, Int(horizontalSizeClass)) - encoder.intField(11, Int(verticalSizeClass)) +@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 + ) + ) } - package init(from decoder: inout ProtobufDecoder) throws { - var catalogKey = CatalogKey(colorScheme: .light, contrast: .standard) - var name = "" - var scale: CGFloat = 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 + /// 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 + ) + ) + } - 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: Int = try decoder.intField(field) - layoutDirection = value == 1 ? .rightToLeft : .leftToRight - case 7: - let value: Int = try decoder.intField(field) - gamut = DisplayGamut(rawValue: value) ?? .sRGB - case 8: idiom = try decoder.intField(field) - case 9: subtype = try decoder.intField(field) - case 10: horizontalSizeClass = Int8(try decoder.intField(field) as Int) - case 11: verticalSizeClass = Int8(try decoder.intField(field) as Int) - default: try decoder.skipField(field) - } - } + /// 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( - catalogKey: catalogKey, - name: name, - scale: scale, - location: location, - layoutDirection: layoutDirection, - locale: .autoupdatingCurrent, - gamut: gamut, - idiom: idiom, - subtype: subtype, - horizontalSizeClass: horizontalSizeClass, - verticalSizeClass: verticalSizeClass + 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 + ) ) } } @@ -1040,6 +1280,100 @@ extension Image { @available(*, unavailable) extension Image.ResolvedUUID: Sendable {} +// MARK: - NamedImage.Key + ProtobufMessage + +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: + // TODO: UUID protobuf encoding + break + } + } + + 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)) + default: + try decoder.skipField(field) + } + } + guard let result else { + throw ProtobufDecoder.DecodingError.failed + } + self = result + } +} + +// MARK: - NamedImage.BitmapKey + ProtobufMessage + +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) + try encoder.messageField(4, location) + encoder.intField(5, layoutDirection == .rightToLeft ? 1 : 0) + // locale encoding omitted - Locale does not yet conform to ProtobufMessage + encoder.intField(7, gamut.rawValue) + encoder.intField(8, idiom) + encoder.intField(9, subtype) + encoder.intField(10, Int(horizontalSizeClass)) + encoder.intField(11, Int(verticalSizeClass)) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var catalogKey = CatalogKey(colorScheme: .light, contrast: .standard) + var name = "" + var scale: CGFloat = 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 + + 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: Int = try decoder.intField(field) + layoutDirection = value == 1 ? .rightToLeft : .leftToRight + case 7: + let value: Int = try decoder.intField(field) + gamut = DisplayGamut(rawValue: value) ?? .sRGB + case 8: idiom = try decoder.intField(field) + case 9: subtype = try decoder.intField(field) + case 10: horizontalSizeClass = Int8(try decoder.intField(field) as Int) + case 11: verticalSizeClass = Int8(try decoder.intField(field) as Int) + default: try decoder.skipField(field) + } + } + self.init( + catalogKey: catalogKey, + name: name, + scale: scale, + location: location, + layoutDirection: layoutDirection, + locale: .autoupdatingCurrent, + gamut: gamut, + idiom: idiom, + subtype: subtype, + horizontalSizeClass: horizontalSizeClass, + verticalSizeClass: verticalSizeClass + ) + } +} + // MARK: - CUI Helpers #if OPENSWIFTUI_LINK_COREUI diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImageProvider.swift b/Sources/OpenSwiftUICore/View/Image/NamedImageProvider.swift deleted file mode 100644 index 0a26fb3d1..000000000 --- a/Sources/OpenSwiftUICore/View/Image/NamedImageProvider.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// NamedImageProvider.swift -// OpenSwiftUICore -// -// Audited for 6.5.4 -// Status: WIP -// ID: ? (SwiftUICore) - -package import Foundation -package import OpenCoreGraphicsShims - -// MARK: - Image.NamedImageProvider - -extension Image { - 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 { - // TODO: Full CoreUI-based resolution - // The real implementation: - // 1. Tries vector resolution first (via vectorInfo) - // 2. Falls back to bitmap resolution (via bitmapInfo) - // 3. Returns resolveError if both fail - resolveError(in: context.environment) - } - - package func resolveError(in environment: EnvironmentValues) -> Image.Resolved { - Image.Resolved( - image: GraphicsImage( - contents: nil, - scale: environment.displayScale, - unrotatedPixelSize: .zero, - orientation: .up, - isTemplate: false - ), - decorative: decorative, - label: label - ) - } - - package func resolveNamedImage(in context: ImageResolutionContext) -> Image.NamedResolved? { - let environment = context.environment - let isTemplate = environment.imageIsTemplate() - return Image.NamedResolved( - name: name, - location: location, - value: value, - symbolRenderingMode: context.symbolRenderingMode?.storage, - isTemplate: isTemplate, - environment: environment - ) - } - - package static func == (a: NamedImageProvider, b: NamedImageProvider) -> Bool { - a.name == b.name - && a.value == b.value - && a.location == b.location - && a.backupLocation == b.backupLocation - && a.label == b.label - && a.decorative == b.decorative - } - } -} From 92886749a645f770f544f6082c8523342e9fe915 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 1 Mar 2026 20:59:22 +0800 Subject: [PATCH 13/19] Implement NamedImageProvider --- .../View/Image/GraphicsImage.swift | 18 ++ .../OpenSwiftUICore/View/Image/Image.swift | 2 + .../View/Image/NamedImage.swift | 235 ++++++++++++++++-- .../View/Image/ResolvedImage.swift | 28 +++ .../View/Image/SymbolVariants.swift | 18 ++ .../View/Image/NamedImageTests.swift | 71 ++++++ 6 files changed, 357 insertions(+), 15 deletions(-) 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 21e31f20e..79909e150 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -232,7 +232,7 @@ package enum NamedImage { } - // MARK: - NamedImage.BitmapKey [WIP] + // MARK: - NamedImage.BitmapKey [TBA] package struct BitmapKey: Hashable { package var catalogKey: CatalogKey @@ -486,7 +486,7 @@ package enum NamedImage { case missingUUIDImage } - // MARK: - NamedImage.Cache + // MARK: - NamedImage.Cache [TBA] package struct Cache { private struct ImageCacheData { @@ -516,7 +516,7 @@ package enum NamedImage { #if OPENSWIFTUI_LINK_COREUI // Looks up cached VectorInfo for key; if not found or catalog changed, // calls loadVectorInfo and caches the result. - private subscript(key: VectorKey, catalog: CUICatalog) -> VectorInfo? { + fileprivate subscript(key: VectorKey, catalog: CUICatalog) -> VectorInfo? { let cached = data.vectors[key] if let cached { if let cachedCatalog = cached.catalog, cachedCatalog == catalog { @@ -618,10 +618,10 @@ extension Image { public static var _mainNamedBundle: Bundle? { nil } } -// MARK: - Image.Location - @available(OpenSwiftUI_v1_0, *) extension Image { + // MARK: - Image.Location [TBA] + package enum Location: Equatable, Hashable { case bundle(Bundle) case system @@ -887,19 +887,42 @@ extension Image { } package func resolve(in context: ImageResolutionContext) -> Image.Resolved { - // TODO: Full CoreUI-based resolution - // The real implementation: - // 1. Tries vector resolution first (via vectorInfo) - // 2. Falls back to bitmap resolution (via bitmapInfo) - // 3. Returns resolveError if both fail - resolveError(in: context.environment) + #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 { - 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: environment.displayScale, + scale: 1.0, unrotatedPixelSize: .zero, orientation: .up, isTemplate: false @@ -909,14 +932,196 @@ extension Image { ) } + #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 isTemplate = environment.imageIsTemplate() + 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: context.symbolRenderingMode?.storage, + symbolRenderingMode: symbolRenderingMode?.storage, isTemplate: isTemplate, environment: environment ) diff --git a/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift b/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift index 28b925caa..1fa077c0e 100644 --- a/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/ResolvedImage.swift @@ -40,6 +40,34 @@ extension Image { } #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 diff --git a/Sources/OpenSwiftUICore/View/Image/SymbolVariants.swift b/Sources/OpenSwiftUICore/View/Image/SymbolVariants.swift index 37e6b8dd9..20f42adad 100644 --- a/Sources/OpenSwiftUICore/View/Image/SymbolVariants.swift +++ b/Sources/OpenSwiftUICore/View/Image/SymbolVariants.swift @@ -473,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/Tests/OpenSwiftUICoreTests/View/Image/NamedImageTests.swift b/Tests/OpenSwiftUICoreTests/View/Image/NamedImageTests.swift index d4a87e12d..27b997e96 100644 --- a/Tests/OpenSwiftUICoreTests/View/Image/NamedImageTests.swift +++ b/Tests/OpenSwiftUICoreTests/View/Image/NamedImageTests.swift @@ -410,4 +410,75 @@ struct NamedImageProviderTests { ) #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) + } } From c508aa703308f4a21c15a3eca21e7a424b45a696 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 2 Mar 2026 02:00:32 +0800 Subject: [PATCH 14/19] Update Image.Location + PB support --- .../Data/Protobuf/ProtobufEncoder.swift | 14 ++++ .../View/Image/NamedImage.swift | 68 +++++++++++++------ 2 files changed, 61 insertions(+), 21 deletions(-) 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/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index 79909e150..4a837040d 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -1297,42 +1297,68 @@ extension Image { } } -// MARK: - Image.Location + ProtobufMessage +// 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): - encoder.intField(1, 2) - try encoder.stringField(2, bundle.bundlePath) + try encoder.messageField(1, BundlePath(value: bundle.bundlePath)) case .system: - encoder.intField(1, 0) + encoder.emptyField(2) case .privateSystem: - encoder.intField(1, 1) + encoder.emptyField(3) } } + private enum Error: Swift.Error { + case invalidBundle(String) + } + package init(from decoder: inout ProtobufDecoder) throws { - var discriminator: Int = 0 - var path: String? + var result: Image.Location = .system while let field = try decoder.nextField() { switch field.tag { - case 1: discriminator = try decoder.intField(field) - case 2: path = try decoder.stringField(field) - default: try decoder.skipField(field) - } - } - switch discriminator { - case 0: self = .system - case 1: self = .privateSystem - case 2: - if let path, let bundle = Bundle(path: path) { - self = .bundle(bundle) - } else { - self = .system + 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) } - default: self = .system } + self = result } } From 7ce3b4607c93d5aadaf36a6ddb157388cb96c1f9 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 2 Mar 2026 02:14:19 +0800 Subject: [PATCH 15/19] Update Image.HashableScale --- .../EnvironmentAdditions.swift | 12 +-- .../View/Image/NamedImage.swift | 77 +++++++++---------- 2 files changed, 40 insertions(+), 49 deletions(-) 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/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index 4a837040d..4d75ddcf5 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -1362,17 +1362,14 @@ extension Image.Location: ProtobufMessage { } } -// MARK: - Image.HashableScale [WIP] +// MARK: - Image.HashableScale extension Image { /// A hashable representation of `Image.Scale` for use as a cache key. package enum HashableScale: Hashable { - case small - case medium - case large - case ccSmall - case ccMedium - case ccLarge + case small, medium, large + case ccSmall, ccMedium, ccLarge + case wtMedium package init(_ scale: Image.Scale) { switch scale { @@ -1382,27 +1379,26 @@ extension Image { case ._controlCenter_small: self = .ccSmall case ._controlCenter_medium: self = .ccMedium case ._controlCenter_large: self = .ccLarge - default: self = .medium + case ._watch_toolbar_medium: self = .wtMedium } } - // MARK: - Helpers for symbol sizing - /// 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: return 2 + 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" - package var allowedScaleRange: ClosedRange { + /// - 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 @@ -1411,42 +1407,43 @@ extension Image { 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 - precondition(lower <= upper, "xx") return lower ... upper #else return 0.0 ... .greatestFiniteMagnitude #endif + case .wtMedium: + return 0.75 ... 1.0 } } - // Weight interpolation constants per scale category: - // (lightValue, nominalValue, heavyValue) - // where 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. - private static let smallConstants: (light: Double, nominal: Double, heavy: Double) = (74.46, 78.86, 83.98) - private static let mediumConstants: (light: Double, nominal: Double, heavy: Double) = (94.63, 99.61, 106.64) - private static let largeConstants: (light: Double, nominal: Double, heavy: Double) = (121.66, 127.2, 135.89) - - /// Computes the diameter of a circle.fill symbol for the given point size and weight. + /// 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). - package func circleDotFillSize(pointSize: CGFloat, weight: Font.Weight) -> CGFloat { + /// + /// 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) - // Discriminator bitmask: medium/ccMedium = 0x52, small/ccSmall = 0x9 switch self { - case .medium, .ccMedium: - constants = Self.mediumConstants + case .medium, .ccMedium, .wtMedium: + constants = mediumConstants case .small, .ccSmall: - constants = Self.smallConstants + constants = smallConstants default: // large, ccLarge - constants = Self.largeConstants + constants = largeConstants } let percentage: CGFloat @@ -1465,10 +1462,11 @@ extension Image { /// 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). - package func maxRadius(diameter: CGFloat) -> CGFloat { + /// 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 { @@ -1481,8 +1479,9 @@ extension Image { let scale: CGFloat = 1.2 #endif radius *= scale + case .wtMedium: + radius *= 1.1 } - return radius } } From 09c4779fc1b4eabb98f8013b5dd580d5c1d46065 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 2 Mar 2026 02:30:43 +0800 Subject: [PATCH 16/19] Add UUIDImageProvider --- .../View/Image/NamedImage.swift | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index 4d75ddcf5..857566075 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -1487,16 +1487,60 @@ extension Image { } } -// MARK: - Image.ResolvedUUID [WIP] +// 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 { - package var cgImage: CGImage - package var scale: CGFloat - package var orientation: Image.Orientation + var cgImage: CGImage + var scale: CGFloat + var orientation: Image.Orientation package init(cgImage: CGImage, scale: CGFloat, orientation: Image.Orientation) { self.cgImage = cgImage From 0cd243aa2b4c12506b01f792d2c4556468c3d94e Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 2 Mar 2026 03:11:09 +0800 Subject: [PATCH 17/19] Add NamedImage.Key + ProtobufMessage --- .../View/Image/NamedImage.swift | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index 857566075..71d0c2da6 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -1554,16 +1554,15 @@ extension Image { @available(*, unavailable) extension Image.ResolvedUUID: Sendable {} -// MARK: - NamedImage.Key + ProtobufMessage +// 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: - // TODO: UUID protobuf encoding - break + case .uuid(let uuid): + try encoder.messageField(2, uuid) } } @@ -1573,6 +1572,8 @@ extension NamedImage.Key: ProtobufMessage { switch field.tag { case 1: result = .bitmap(try decoder.messageField(field)) + case 2: + result = .uuid(try decoder.messageField(field)) default: try decoder.skipField(field) } @@ -1584,27 +1585,27 @@ extension NamedImage.Key: ProtobufMessage { } } -// MARK: - NamedImage.BitmapKey + ProtobufMessage +// 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) + encoder.cgFloatField(3, scale, defaultValue: 1.0) try encoder.messageField(4, location) - encoder.intField(5, layoutDirection == .rightToLeft ? 1 : 0) - // locale encoding omitted - Locale does not yet conform to ProtobufMessage - encoder.intField(7, gamut.rawValue) - encoder.intField(8, idiom) - encoder.intField(9, subtype) - encoder.intField(10, Int(horizontalSizeClass)) - encoder.intField(11, Int(verticalSizeClass)) + 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 = 0 + var scale: CGFloat = 1.0 var location: Image.Location = .system var layoutDirection: LayoutDirection = .leftToRight var gamut: DisplayGamut = .sRGB @@ -1612,6 +1613,7 @@ extension NamedImage.BitmapKey: ProtobufMessage { 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 { @@ -1620,15 +1622,16 @@ extension NamedImage.BitmapKey: ProtobufMessage { case 3: scale = try decoder.cgFloatField(field) case 4: location = try decoder.messageField(field) case 5: - let value: Int = try decoder.intField(field) - layoutDirection = value == 1 ? .rightToLeft : .leftToRight - case 7: - let value: Int = try decoder.intField(field) - gamut = DisplayGamut(rawValue: value) ?? .sRGB - case 8: idiom = try decoder.intField(field) - case 9: subtype = try decoder.intField(field) - case 10: horizontalSizeClass = Int8(try decoder.intField(field) as Int) - case 11: verticalSizeClass = Int8(try decoder.intField(field) as Int) + 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) } } @@ -1638,7 +1641,7 @@ extension NamedImage.BitmapKey: ProtobufMessage { scale: scale, location: location, layoutDirection: layoutDirection, - locale: .autoupdatingCurrent, + locale: locale, gamut: gamut, idiom: idiom, subtype: subtype, @@ -1648,10 +1651,10 @@ extension NamedImage.BitmapKey: ProtobufMessage { } } -// MARK: - CUI Helpers - #if OPENSWIFTUI_LINK_COREUI +// MARK: - CUI Helpers + extension Font.Weight { /// Maps Font.Weight to CUI's `_CUIThemeVectorGlyphWeight` values. /// From 668f4b242c07bf5c2b035cdd4d4edfc3dc818f66 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 2 Mar 2026 03:22:18 +0800 Subject: [PATCH 18/19] Update Package.resolved --- Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index 1f5562c6e..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" } }, { From 5447cf3d309c80df8f1c3d47bf800de6b8ec48e6 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 2 Mar 2026 03:44:26 +0800 Subject: [PATCH 19/19] Add SFSymbolsShims --- .../OpenSwiftUICore/Util/SFSymbolsShims.swift | 77 +++++++++++++++++++ .../View/Image/NamedImage.swift | 20 ++--- .../Util/SFSymbolsShimsTests.swift | 48 ++++++++++++ 3 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 Sources/OpenSwiftUICore/Util/SFSymbolsShims.swift create mode 100644 Tests/OpenSwiftUICoreTests/Util/SFSymbolsShimsTests.swift 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/Image/NamedImage.swift b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift index 71d0c2da6..68eb97b4a 100644 --- a/Sources/OpenSwiftUICore/View/Image/NamedImage.swift +++ b/Sources/OpenSwiftUICore/View/Image/NamedImage.swift @@ -731,22 +731,14 @@ extension Image { package init(internalUse: Bool) { let bundlePath: String if internalUse { - // TODO: Load from SFSymbols private framework - // fillMapping = SFSymbols.private_nofill_to_fill - // nameAliases = SFSymbols.private_name_aliases - // symbols = SFSymbols.private_symbol_order - fillMapping = [:] - nameAliases = [:] - symbols = [] + fillMapping = SFSymbols.private_nofill_to_fill + nameAliases = SFSymbols.private_name_aliases + symbols = SFSymbols.private_symbol_order bundlePath = "/System/Library/CoreServices/CoreGlyphsPrivate.bundle" } else { - // TODO: Load from SFSymbols framework - // fillMapping = SFSymbols.nofill_to_fill - // nameAliases = SFSymbols.name_aliases - // symbols = SFSymbols.symbol_order - fillMapping = [:] - nameAliases = [:] - symbols = [] + fillMapping = SFSymbols.nofill_to_fill + nameAliases = SFSymbols.name_aliases + symbols = SFSymbols.symbol_order bundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle" } #if OPENSWIFTUI_LINK_COREUI 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