From 7f958a63e1c692f6af9739f7cf02b7473f091648 Mon Sep 17 00:00:00 2001 From: Gurmeher Chawla Date: Mon, 3 Aug 2026 02:10:25 +0530 Subject: [PATCH] Implement Color.mix(with:by:in:) for Compose Mixes colors in the perceptual (Oklab) color space via Compose's androidx.compose.ui.graphics.lerp, matching SwiftUI's default, or by component-wise sRGB interpolation for Gradient.ColorSpace.device. Gives Gradient.ColorSpace an identifier so .device and .perceptual are distinguishable, and adds a bridged variant for skip-fuse-ui support. Co-Authored-By: Claude Fable 5 --- Sources/SkipUI/SkipUI/Color/Color.swift | 29 +++++++++++++++++ Sources/SkipUI/SkipUI/Graphics/Gradient.swift | 10 ++++-- Tests/SkipUITests/ColorTests.swift | 31 +++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Color/Color.swift b/Sources/SkipUI/SkipUI/Color/Color.swift index b3508e2c..048991b2 100644 --- a/Sources/SkipUI/SkipUI/Color/Color.swift +++ b/Sources/SkipUI/SkipUI/Color/Color.swift @@ -10,6 +10,8 @@ import androidx.compose.material.ContentAlpha import androidx.compose.material3.MaterialTheme import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.unit.dp #endif @@ -340,6 +342,33 @@ public struct Color: ShapeStyle, Renderable, Hashable { #endif } + public func mix(with rhs: Color, by fraction: Double, in colorSpace: Gradient.ColorSpace = .perceptual) -> Color { + #if SKIP + let clampedFraction = Self.clamp(fraction) + return Color(colorImpl: { + if colorSpace == .device { + let start = colorImpl().convert(ColorSpaces.Srgb) + let stop = rhs.colorImpl().convert(ColorSpaces.Srgb) + return androidx.compose.ui.graphics.Color( + red: start.red + (stop.red - start.red) * clampedFraction, + green: start.green + (stop.green - start.green) * clampedFraction, + blue: start.blue + (stop.blue - start.blue) * clampedFraction, + alpha: start.alpha + (stop.alpha - start.alpha) * clampedFraction) + } else { + // Compose's lerp interpolates in the Oklab color space, matching SwiftUI's .perceptual + return lerp(colorImpl(), rhs.colorImpl(), clampedFraction) + } + }) + #else + return self + #endif + } + + // SKIP @bridge + public func mix(with rhs: Color, by fraction: Double, bridgedColorSpace: Int) -> Color { + return mix(with: rhs, by: fraction, in: bridgedColorSpace == 0 ? .device : .perceptual) + } + // SKIP @bridge public func saturate(by multiplier: Double) -> Color { #if SKIP diff --git a/Sources/SkipUI/SkipUI/Graphics/Gradient.swift b/Sources/SkipUI/SkipUI/Graphics/Gradient.swift index 0667a891..81cac19d 100644 --- a/Sources/SkipUI/SkipUI/Graphics/Gradient.swift +++ b/Sources/SkipUI/SkipUI/Graphics/Gradient.swift @@ -71,8 +71,14 @@ public struct Gradient : ShapeStyle, Hashable { #endif public struct ColorSpace : Hashable { - public static let device = Gradient.ColorSpace() - public static let perceptual = Gradient.ColorSpace() + let identifier: Int + + init(identifier: Int) { + self.identifier = identifier + } + + public static let device = Gradient.ColorSpace(identifier: 0) + public static let perceptual = Gradient.ColorSpace(identifier: 1) } public func colorSpace(_ space: Gradient.ColorSpace) -> AnyGradient { diff --git a/Tests/SkipUITests/ColorTests.swift b/Tests/SkipUITests/ColorTests.swift index a26095b3..cba24002 100644 --- a/Tests/SkipUITests/ColorTests.swift +++ b/Tests/SkipUITests/ColorTests.swift @@ -14,6 +14,37 @@ final class ColorTests: XCSnapshotTestCase { XCTAssertEqual("F", try render(compact: 1, view: Color.white.frame(width: 1.0, height: 1.0)).pixmap) } + func testColorMixFullFraction() throws { + if #available(iOS 18.0, macOS 15.0, *) { + XCTAssertEqual("0", try render(compact: 1, view: Color.white.mix(with: Color.black, by: 1.0).frame(width: 1.0, height: 1.0)).pixmap) + } + } + + func testColorMixFractionClampedAbove() throws { + if #available(iOS 18.0, macOS 15.0, *) { + XCTAssertEqual("F", try render(compact: 1, view: Color.black.mix(with: Color.white, by: 2.0).frame(width: 1.0, height: 1.0)).pixmap) + } + } + + func testColorMixFractionClampedBelow() throws { + if #available(iOS 18.0, macOS 15.0, *) { + XCTAssertEqual("0", try render(compact: 1, view: Color.black.mix(with: Color.white, by: -1.0).frame(width: 1.0, height: 1.0)).pixmap) + } + } + + func testColorMixDevice() throws { + if #available(iOS 18.0, macOS 15.0, *) { + XCTAssertEqual(plaf("808080"), try render(view: Color.black.mix(with: Color.white, by: 0.5, in: .device).frame(width: 1.0, height: 1.0)).pixmap) + } + } + + func testColorMixPerceptual() throws { + if #available(iOS 18.0, macOS 15.0, *) { + // A 50% black/white mix in the default perceptual (Oklab) color space resolves to Oklab L = 0.5, i.e. #636363 + XCTAssertEqual(plaf("636363"), try render(view: Color.black.mix(with: Color.white, by: 0.5).frame(width: 1.0, height: 1.0)).pixmap) + } + } + // Issue #146 follow-up: all three .colorset Input Methods must render the same color (#04F188). // Skipped on Robolectric — Bundle.module colorset decoding falls back to gray there (pre-existing runner limitation).