diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 96926b9..ac9eb09 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,8 +12,8 @@ jobs: - uses: actions/checkout@v4 - name: Build package - run: swift build + run: swift build --build-system swiftbuild - name: Run tests if: hashFiles('Tests/**') != '' - run: swift test + run: swift test --build-system swiftbuild diff --git a/Package.swift b/Package.swift index a41d98f..8d2b267 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,7 @@ let package = Package( name: "HandySwiftUI", dependencies: [.product(name: "HandySwift", package: "HandySwift")], resources: [.process("Localizable.xcstrings")] - ) + ), + .testTarget(name: "HandySwiftUITests", dependencies: ["HandySwiftUI"]), ] ) diff --git a/Sources/HandySwiftUI/Extensions/ColorExt.swift b/Sources/HandySwiftUI/Extensions/ColorExt.swift index 15b989d..599ad8a 100644 --- a/Sources/HandySwiftUI/Extensions/ColorExt.swift +++ b/Sources/HandySwiftUI/Extensions/ColorExt.swift @@ -339,14 +339,18 @@ extension Color { } // MARK: - Computed Properties - /// The HSB & alpha attributes of the `Color` instance. + /// The HSB & alpha attributes of the `Color` instance, based on its extended sRGB components (like ``rgbo``). + /// + /// For wide-gamut colors (e.g. Display P3), saturation and brightness can be above 1. `Color(hue:saturation:brightness:opacity:)` + /// accepts such values and recreates the exact color. public var hsbo: (hue: Double, saturation: Double, brightness: Double, opacity: Double) { var hue: CGFloat = 0 var saturation: CGFloat = 0 var brightness: CGFloat = 0 var opacity: CGFloat = 0 #if os(macOS) - let color = NativeColor(self).usingColorSpace(.deviceRGB)! + // Extended sRGB (like UIColor and `rgbo`) instead of device RGB, which clips wide-gamut colors + let color = NativeColor(self).usingColorSpace(.extendedSRGB)! #else let color = NativeColor(self) #endif @@ -356,7 +360,15 @@ extension Color { return (hue: Double(hue), saturation: Double(saturation), brightness: Double(brightness), opacity: Double(opacity)) } - /// The RGB & alpha attributes of the `Color` instance. + /// The RGB & alpha attributes of the `Color` instance in the extended sRGB color space. + /// + /// Dynamic colors (like `.primary` or `.systemBlue`) resolve through `UIColor`/`NSColor` against the traits current at the time of + /// the call: `UITraitCollection.current` on iOS-based platforms, which outside of trait-aware callbacks usually holds default + /// traits, and `NSAppearance.currentDrawing()` on macOS. For the values a view actually displays, use ``rgbo(in:)`` with the view's + /// environment, which also respects overrides like `.environment(\.colorScheme, .dark)`. + /// + /// Wide-gamut colors (e.g. Display P3) can have components below 0 or above 1, as in `Color.Resolved`. Clamp them before + /// converting to a fixed range, e.g. `UInt8(min(max(red, 0), 1) * 255)`. public var rgbo: (red: Double, green: Double, blue: Double, opacity: Double) { var red: CGFloat = 0 var green: CGFloat = 0 @@ -364,7 +376,8 @@ extension Color { var opacity: CGFloat = 0 #if os(macOS) - let color = NativeColor(self).usingColorSpace(.deviceRGB)! + // Extended sRGB (like UIColor and `Color.Resolved`) instead of device RGB, which clips wide-gamut colors + let color = NativeColor(self).usingColorSpace(.extendedSRGB)! #else let color = NativeColor(self) #endif @@ -373,6 +386,32 @@ extension Color { return (red: Double(red), green: Double(green), blue: Double(blue), opacity: Double(opacity)) } + /// The RGB & alpha attributes of the `Color` instance in the extended sRGB color space, resolved in the given environment. + /// + /// Use this inside a view to get the components the view actually displays, e.g. the dark variant of a dynamic color in dark mode: + /// + /// ```swift + /// struct ColorInfoView: View { + /// @Environment(\.self) private var environment + /// + /// var body: some View { + /// let components = Color.primary.rgbo(in: self.environment) + /// Text("Red: \(components.red)") + /// } + /// } + /// ``` + /// + /// - Note: Uses `Color.resolve(in:)` starting with iOS 17, macOS 14, tvOS 17, watchOS 10 and visionOS 1. On older versions, the + /// environment is ignored and the result equals ``rgbo``. + public func rgbo(in environment: EnvironmentValues) -> (red: Double, green: Double, blue: Double, opacity: Double) { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1, *) { + let resolved = self.resolve(in: environment) + return (red: Double(resolved.red), green: Double(resolved.green), blue: Double(resolved.blue), opacity: Double(resolved.opacity)) + } else { + return self.rgbo + } + } + /// The HLC & alpha attributes of the `Color` instance. public var hlco: (hue: Double, luminance: Double, chroma: Double, opacity: Double) { let lch = self.rgbColor().toLCH() diff --git a/Sources/HandySwiftUI/HandySwiftUI.docc/Essentials/Extensions.md b/Sources/HandySwiftUI/HandySwiftUI.docc/Essentials/Extensions.md index ba3d4dc..d7a65ac 100644 --- a/Sources/HandySwiftUI/HandySwiftUI.docc/Essentials/Extensions.md +++ b/Sources/HandySwiftUI/HandySwiftUI.docc/Essentials/Extensions.md @@ -76,6 +76,14 @@ struct ColorfulView: View { When adjusting color brightness, use .luminance instead of .brightness from the HSB color system. Luminance better represents how humans perceive light and dark, which is why HandySwiftUI includes support for the HLC color space. +Inside a view, `rgbo(in:)` reads the components in the view's environment, so dynamic colors like `.primary` resolve exactly as the view displays them, including overrides like `.environment(\.colorScheme, .dark)`: + +```swift +@Environment(\.self) private var environment + +let components = Color.primary.rgbo(in: environment) +``` + ### Rich Text Formatting @@ -268,6 +276,7 @@ The highlighting is case-insensitive and diacritic-insensitive by default, but y - ``SwiftUICore/Color/hlco`` - ``SwiftUICore/Color/hsbo`` - ``SwiftUICore/Color/rgbo`` +- ``SwiftUICore/Color/rgbo(in:)`` - ``SwiftUICore/Color/init(hex:)`` - ``SwiftUICore/Color/init(hue:luminance:chroma:opacity:)`` - ``SwiftUICore/Color/toHex(alpha:)`` diff --git a/Sources/HandySwiftUI/HandySwiftUI.docc/Essentials/New Types.md b/Sources/HandySwiftUI/HandySwiftUI.docc/Essentials/New Types.md index e26287e..4aedbff 100644 --- a/Sources/HandySwiftUI/HandySwiftUI.docc/Essentials/New Types.md +++ b/Sources/HandySwiftUI/HandySwiftUI.docc/Essentials/New Types.md @@ -366,7 +366,6 @@ struct MainView: View { - ``VPicker`` - ``SearchableGridPicker`` - ``SideTabView`` -- ``WebView`` ### Other @@ -379,6 +378,12 @@ struct MainView: View { - ``SFSymbol`` - ``Xcode`` +### Deprecated + +Starting with iOS 26, macOS 26 and visionOS 26, use the `WebView` that WebKit provides for SwiftUI instead. + +- ``WebView`` + [TranslateKit]: https://translatekit.app [FreemiumKit]: https://freemiumkit.app diff --git a/Sources/HandySwiftUI/Types/Views/WebView.swift b/Sources/HandySwiftUI/Types/Views/WebView.swift index b5e5009..bb40955 100644 --- a/Sources/HandySwiftUI/Types/Views/WebView.swift +++ b/Sources/HandySwiftUI/Types/Views/WebView.swift @@ -24,6 +24,28 @@ /// } /// } /// ``` + /// + /// - Note: Deprecated starting with iOS 26, macOS 26 and visionOS 26, where WebKit ships its own SwiftUI `WebView`. Both types share + /// the same name, so in files that import both WebKit and HandySwiftUI, using `WebView` as a type (e.g. for a stored property) is + /// ambiguous and needs to be qualified as `HandySwiftUI.WebView` until the migration is done. To migrate, use + /// WebKit's `WebView(url:)` directly, or `WebView(_:)` with a `WebPage`: a custom `WebPage.NavigationDeciding` replaces + /// `onOpenExternalURL` (open the link via `openURL` and return `.cancel`), and `callJavaScript(_:)` or the + /// `webViewScrollPosition(_:)` modifier replaces `scrollOffsetOnPageLoad`. + @available( + iOS, + deprecated: 26.0, + message: "Since iOS 26, WebKit provides a native SwiftUI `WebView`. Use it with a `WebPage` whose `NavigationDeciding` opens link taps externally." + ) + @available( + macOS, + deprecated: 26.0, + message: "Since macOS 26, WebKit provides a native SwiftUI `WebView`. Use it with a `WebPage` whose `NavigationDeciding` opens link taps externally." + ) + @available( + visionOS, + deprecated: 26.0, + message: "Since visionOS 26, WebKit provides a native SwiftUI `WebView`. Use it with a `WebPage` whose `NavigationDeciding` opens link taps externally." + ) public struct WebView: View { #if canImport(UIKit) typealias NativeViewRepresentable = UIViewRepresentable diff --git a/Tests/HandySwiftUITests/ColorComponentsTests.swift b/Tests/HandySwiftUITests/ColorComponentsTests.swift new file mode 100644 index 0000000..79c75d0 --- /dev/null +++ b/Tests/HandySwiftUITests/ColorComponentsTests.swift @@ -0,0 +1,107 @@ +import SwiftUI +import Testing + +@testable import HandySwiftUI + +@MainActor +struct ColorComponentsTests { + typealias Components = (red: Double, green: Double, blue: Double, opacity: Double) + + nonisolated static let srgbColors: [(String, Color)] = [ + ("red", .red), + ("blue", .blue), + ("rgb", Color(red: 0.2, green: 0.4, blue: 0.6)), + ("srgbWithOpacity", Color(.sRGB, red: 0.9, green: 0.1, blue: 0.3, opacity: 0.5)), + ("gray", Color(white: 0.5)), + ("hex", Color(hex: "#FF5733")), + ("primary", .primary), + ("systemBlue", .systemBlue), + ("label", .label), + ] + + static func environment(_ colorScheme: ColorScheme) -> EnvironmentValues { + var environment = EnvironmentValues() + environment.colorScheme = colorScheme + return environment + } + + func expectEqual(_ lhs: Components, _ rhs: Components, _ comment: Comment) { + let tolerance = 0.0005 + #expect(abs(lhs.red - rhs.red) < tolerance, comment) + #expect(abs(lhs.green - rhs.green) < tolerance, comment) + #expect(abs(lhs.blue - rhs.blue) < tolerance, comment) + #expect(abs(lhs.opacity - rhs.opacity) < tolerance, comment) + } + + #if os(macOS) + /// The implementation of `rgbo` before it switched from device RGB to extended sRGB on macOS. + func deviceRGBComponents(of color: Color) -> Components { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var opacity: CGFloat = 0 + NSColor(color).usingColorSpace(.deviceRGB)!.getRed(&red, green: &green, blue: &blue, alpha: &opacity) + return (Double(red), Double(green), Double(blue), Double(opacity)) + } + + var currentColorScheme: ColorScheme { + NSAppearance.currentDrawing().bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? .dark : .light + } + + @Test(arguments: srgbColors) + func srgbColorsKeepTheirComponents(name: String, color: Color) { + self.expectEqual(color.rgbo, self.deviceRGBComponents(of: color), "\(name)") + } + + /// With "Increase contrast" on, `rgbo` returns the high-contrast variants, which an `EnvironmentValues()` cannot request. + @Test( + .disabled("The system setting \"Increase contrast\" is on") { + await MainActor.run { NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast } + }, + arguments: srgbColors + ) + func rgboMatchesResolvedComponentsForTheCurrentAppearance(name: String, color: Color) { + self.expectEqual(color.rgbo, color.rgbo(in: Self.environment(self.currentColorScheme)), "\(name)") + } + + @Test + func wideGamutColorIsNotClipped() { + let displayP3Red = Color(.displayP3, red: 1, green: 0, blue: 0) + + #expect(displayP3Red.rgbo.red > 1.05) + #expect(displayP3Red.rgbo.green < -0.1) + self.expectEqual(displayP3Red.rgbo, displayP3Red.rgbo(in: EnvironmentValues()), "displayP3Red") + } + #endif + + /// Older OS versions have no `resolve(in:)`, there `rgbo(in:)` falls back to `rgbo` and ignores the environment. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1, *) + @Test + func dynamicColorsResolveAgainstTheGivenEnvironment() { + let light = Color.primary.rgbo(in: Self.environment(.light)) + let dark = Color.primary.rgbo(in: Self.environment(.dark)) + + #expect(light.red < 0.1 && light.green < 0.1 && light.blue < 0.1) + #expect(dark.red > 0.9 && dark.green > 0.9 && dark.blue > 0.9) + } + + @Test + func wideGamutColorKeepsItsHSBComponents() { + let displayP3Red = Color(.displayP3, red: 1, green: 0, blue: 0) + let hsbo = displayP3Red.hsbo + + #expect(hsbo.saturation > 1.05) + self.expectEqual( + Color(hue: hsbo.hue, saturation: hsbo.saturation, brightness: hsbo.brightness, opacity: hsbo.opacity).rgbo, + displayP3Red.rgbo, + "HSB round trip" + ) + self.expectEqual(displayP3Red.change(.brightness, by: 0).rgbo, displayP3Red.rgbo, "brightness change by 0") + } + + @Test(arguments: srgbColors) + func staticColorsAreTheSameInEveryEnvironment(name: String, color: Color) { + guard !["red", "blue", "primary", "systemBlue", "label"].contains(name) else { return } + self.expectEqual(color.rgbo(in: Self.environment(.light)), color.rgbo(in: Self.environment(.dark)), "\(name)") + } +}