diff --git a/Pika.xcodeproj/project.pbxproj b/Pika.xcodeproj/project.pbxproj index 6005e2c4..e8f4f41c 100644 --- a/Pika.xcodeproj/project.pbxproj +++ b/Pika.xcodeproj/project.pbxproj @@ -1159,7 +1159,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 70; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Pika/Preview Content\""; DEVELOPMENT_TEAM = TGHU37N6EX; @@ -1173,7 +1173,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.4.1-beta1; + MARKETING_VERSION = 1.4.1-beta3; PRODUCT_BUNDLE_IDENTIFIER = com.superhighfives.Pika; PRODUCT_NAME = Pika; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG TARGET_SPARKLE"; @@ -1191,7 +1191,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 70; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Pika/Preview Content\""; DEVELOPMENT_TEAM = TGHU37N6EX; @@ -1205,7 +1205,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.4.1-beta1; + MARKETING_VERSION = 1.4.1-beta3; PRODUCT_BUNDLE_IDENTIFIER = com.superhighfives.Pika; PRODUCT_NAME = Pika; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TARGET_SPARKLE; @@ -1238,7 +1238,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.4.1-beta1; + MARKETING_VERSION = 1.4.1-beta3; PRODUCT_BUNDLE_IDENTIFIER = com.superhighfives.Pika; PRODUCT_NAME = Pika; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG TARGET_MAS"; @@ -1271,7 +1271,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.4.1-beta1; + MARKETING_VERSION = 1.4.1-beta3; PRODUCT_BUNDLE_IDENTIFIER = com.superhighfives.Pika; PRODUCT_NAME = Pika; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TARGET_MAS; diff --git a/Pika/Extensions/NSColor+RGB.swift b/Pika/Extensions/NSColor+RGB.swift index 7ce88d76..7412d681 100644 --- a/Pika/Extensions/NSColor+RGB.swift +++ b/Pika/Extensions/NSColor+RGB.swift @@ -37,8 +37,8 @@ extension NSColor { } } - func toRGB8BitArray() -> [Int] { - let RGB = toRGBAComponents() + func toRGB8BitArray(in colorSpace: NSColorSpace = .sRGB) -> [Int] { + let RGB = toRGBAComponents(in: colorSpace) let red = Int(round(RGB.r * 255)) let green = Int(round(RGB.g * 255)) let blue = Int(round(RGB.b * 255)) diff --git a/Pika/Services/ClosestVector.swift b/Pika/Services/ClosestVector.swift index 1504c674..b10394f6 100644 --- a/Pika/Services/ClosestVector.swift +++ b/Pika/Services/ClosestVector.swift @@ -1,6 +1,5 @@ import AppKit import Cocoa -import Defaults public class ClosestVector { public var list: [[Int]] @@ -16,7 +15,7 @@ public class ClosestVector { } public func compare(_ val: NSColor) -> (Int) { - let color = val.usingColorSpace(Defaults[.colorSpace])! + guard let color = val.usingColorSpace(.sRGB) else { return 0 } let colorArr = [Int(color.redComponent * 255), Int(color.greenComponent * 255), Int(color.blueComponent * 255)] var minDistance = Int.max diff --git a/Pika/Services/Eyedroppers.swift b/Pika/Services/Eyedroppers.swift index 15cec4b4..0fd7d8c1 100644 --- a/Pika/Services/Eyedroppers.swift +++ b/Pika/Services/Eyedroppers.swift @@ -78,14 +78,10 @@ class Eyedropper: ObservableObject { init(type: Types, color: NSColor) { self.type = type - self.color = color + self.color = color.usingColorSpace(.sRGB) ?? color // Load colors closestVector = ClosestVector(colorNames.map { $0.color.toRGB8BitArray() }) - - Defaults.observe(.colorSpace) { change in - self.color = self.color.usingColorSpace(change.newValue)! - }.tieToLifetime(of: self) } func getClosestColor() -> String { @@ -98,17 +94,19 @@ class Eyedropper: ObservableObject { self.set(previousColor) } - color = selectedColor.usingColorSpace(Defaults[.colorSpace])! + color = selectedColor.usingColorSpace(.sRGB) ?? selectedColor } @objc func colorDidChange(sender: AnyObject) { if let picker = sender as? NSColorPanel { + guard let srgbColor = picker.color.usingColorSpace(.sRGB) else { return } + let previousColor = color undoManager?.registerUndo(withTarget: self) { _ in self.set(previousColor) } - color = picker.color.usingColorSpace(Defaults[.colorSpace])! + color = srgbColor } } @@ -139,20 +137,22 @@ class Eyedropper: ObservableObject { sampler.show { selectedColor in if let selectedColor = selectedColor { + let normalizedColor = selectedColor.usingColorSpace(.sRGB) ?? selectedColor + if Defaults[.showColorOverlay] { - let colorText = selectedColor.toFormat( + let colorText = normalizedColor.toFormat( format: Defaults[.colorFormat], style: Defaults[.copyFormat] ) let cursorPosition = NSEvent.mouseLocation self.overlayWindow.show( colorText: colorText, - pickedColor: selectedColor, + pickedColor: normalizedColor, nearCursor: cursorPosition, duration: Defaults[.colorOverlayDuration] ) } - self.set(selectedColor) + self.set(normalizedColor) if Defaults[.copyColorOnPick] { NSApp.sendAction(self.type.copySelector, to: nil, from: nil) diff --git a/Pika/Views/EyedropperButton.swift b/Pika/Views/EyedropperButton.swift index ef870a2c..ed056a5c 100644 --- a/Pika/Views/EyedropperButton.swift +++ b/Pika/Views/EyedropperButton.swift @@ -8,6 +8,7 @@ struct EyedropperButton: View { @Default(.hideColorNames) var hideColorNames @State var hoverVisible: Bool = false + @State private var colorSpace = Defaults[.colorSpace] @State private var hoverTask: Task? @State private var childHovered: Bool = false @@ -24,7 +25,7 @@ struct EyedropperButton: View { .foregroundColor(eyedropper.color.getUIColor().opacity(0.75)) VStack(alignment: .leading, spacing: 6.0) { - Text(eyedropper.color.toFormat(format: colorFormat, style: copyFormat)) + Text((eyedropper.color.usingColorSpace(colorSpace) ?? eyedropper.color).toFormat(format: colorFormat, style: copyFormat)) .foregroundColor(eyedropper.color.getUIColor()) .font(.system(size: 18, weight: .regular)) .padding(.trailing, 32.0) @@ -89,6 +90,9 @@ struct EyedropperButton: View { .padding(.all, 8.0) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) } + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + colorSpace = Defaults[.colorSpace] + } .onHover { hover in if hover { hoverTask?.cancel() diff --git a/PikaTests/NSColorHexTests.swift b/PikaTests/NSColorHexTests.swift index e202f0dd..2b9613d0 100644 --- a/PikaTests/NSColorHexTests.swift +++ b/PikaTests/NSColorHexTests.swift @@ -96,4 +96,29 @@ final class NSColorHexTests: XCTestCase { let color = NSColor(hex: original).usingColorSpace(.sRGB)! XCTAssertEqual(color.toHexString(style: .css), original) } + + // MARK: - sRGB normalization stability + + func test_displayP3Color_normalizedToSRGB_roundTripsExactly() { + // A Display P3 color normalized to sRGB should produce the same hex + // when read back in sRGB, proving the storage path introduces no drift. + let p3Color = NSColor(colorSpace: .displayP3, components: [0.055, 0.094, 0.161, 1.0], count: 4) + let srgbColor = p3Color.usingColorSpace(.sRGB)! + + // Read hex in sRGB (the storage color space) + let hex = srgbColor.toRGBAComponents(in: .sRGB) + let reconstructed = NSColor(srgbRed: hex.r, green: hex.g, blue: hex.b, alpha: hex.a) + let originalHex = srgbColor.toHex() + let roundTrippedHex = reconstructed.toHex() + + XCTAssertEqual(originalHex, roundTrippedHex, "P3 color normalized to sRGB should round-trip without channel drift") + } + + func test_sRGB_roundTrip_noChannelDrift() { + // Verify that converting to sRGB and back to hex doesn't introduce + // the 1-3 channel drift reported in issue #187. + let original = "#0e1829" + let color = NSColor(hex: original).usingColorSpace(.sRGB)! + XCTAssertEqual(color.toHexString(style: .css), original) + } }