From ce6a253d90f5bc99f8bc58ca4e0ebe58e4b9f91b Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 13:31:48 -0700 Subject: [PATCH 01/18] Refactor: Swift code quality improvements across Pika codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Type-safe Notification.Name constants replacing raw string literals - Add typed notification helpers to Eyedropper.Types enum - Replace Combine timer subscriptions with Swift Concurrency (Task/async-await) in ContentView, SwapButtonStyle, EyedropperButton, KeyboardShortcutItem, Toast - Fix @State misuse: KeyboardShortcutItem properties converted to let constants - Deduplicate getBackgroundColor() via Color.pikaControlBackground extension - Deduplicate AppearanceButtonStyle overlay via shared @ViewBuilder function - Data-drive KeyboardShortcutGrid with ShortcutEntry structs (220 → ~60 lines) - Decompose PreferencesView into 6 private sub-view structs - Replace Any type erasure in ComplianceToggleGroup with enum ComplianceData - Consolidate WCAG/APCA conditional logic in Footer with computed properties - Split Cula.swift into NSColor+HSL.swift and NSColor+Lab.swift extensions - Remove dead ColorCompliance protocol - Register new extension files in Xcode project (both targets) Co-Authored-By: Claude Sonnet 4.6 --- Pika.xcodeproj/project.pbxproj | 18 + Pika/AppDelegate.swift | 38 +- Pika/Constants/Constants.swift | 23 ++ Pika/Extensions/Color+PikaControl.swift | 9 + Pika/Extensions/Cula.swift | 306 +------------- Pika/Extensions/NSColor+HSL.swift | 137 +++++++ Pika/Extensions/NSColor+Lab.swift | 138 +++++++ Pika/Styles/AppearanceButtonStyle.swift | 134 ++----- Pika/Styles/CircleButtonStyle.swift | 10 +- Pika/Styles/SwapButtonStyle.swift | 28 +- Pika/Utilities/Eyedroppers.swift | 21 + Pika/Views/ComplianceButtons.swift | 79 ++-- Pika/Views/ComplianceToggleGroup.swift | 151 ++----- Pika/Views/ContentView.swift | 46 +-- Pika/Views/EyedropperButton.swift | 19 +- Pika/Views/EyedropperItem.swift | 125 +++--- Pika/Views/Footer.swift | 37 +- Pika/Views/KeyboardShortcutGrid.swift | 255 ++++-------- Pika/Views/KeyboardShortcutItem.swift | 15 +- Pika/Views/PreferencesView.swift | 507 +++++++++++------------- Pika/Views/Toast.swift | 17 +- 21 files changed, 877 insertions(+), 1236 deletions(-) create mode 100644 Pika/Extensions/Color+PikaControl.swift create mode 100644 Pika/Extensions/NSColor+HSL.swift create mode 100644 Pika/Extensions/NSColor+Lab.swift diff --git a/Pika.xcodeproj/project.pbxproj b/Pika.xcodeproj/project.pbxproj index 22188579..467016bc 100644 --- a/Pika.xcodeproj/project.pbxproj +++ b/Pika.xcodeproj/project.pbxproj @@ -18,6 +18,12 @@ 22FE80B325BA0F820063759E /* KeyboardShortcutItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22FE80B225BA0F820063759E /* KeyboardShortcutItem.swift */; }; C49A11482DB394F500EE7E80 /* APCACompliance.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49A11472DB394F500EE7E80 /* APCACompliance.swift */; }; C49A11492DB394F500EE7E80 /* APCACompliance.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49A11472DB394F500EE7E80 /* APCACompliance.swift */; }; + CC000001000000000000AAA1 /* Color+PikaControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAA0 /* Color+PikaControl.swift */; }; + CC000001000000000000AAA2 /* Color+PikaControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAA0 /* Color+PikaControl.swift */; }; + CC000001000000000000AAB1 /* NSColor+HSL.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAB0 /* NSColor+HSL.swift */; }; + CC000001000000000000AAB2 /* NSColor+HSL.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAB0 /* NSColor+HSL.swift */; }; + CC000001000000000000AAC1 /* NSColor+Lab.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAC0 /* NSColor+Lab.swift */; }; + CC000001000000000000AAC2 /* NSColor+Lab.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAC0 /* NSColor+Lab.swift */; }; EA0C525025AA729300AFF716 /* Visualisation.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C524F25AA729300AFF716 /* Visualisation.swift */; }; EA0C526025AB5A2B00AFF716 /* NavigationMenuItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C525F25AB5A2B00AFF716 /* NavigationMenuItems.swift */; }; EA0C526425AB5D1700AFF716 /* PikaWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C526325AB5D1700AFF716 /* PikaWindow.swift */; }; @@ -152,6 +158,9 @@ C1BF64202C1AE53C004D33DD /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; C1BF64212C1AE53C004D33DD /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Main.strings"; sourceTree = ""; }; C49A11472DB394F500EE7E80 /* APCACompliance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APCACompliance.swift; sourceTree = ""; }; + CC000001000000000000AAA0 /* Color+PikaControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+PikaControl.swift"; sourceTree = ""; }; + CC000001000000000000AAB0 /* NSColor+HSL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+HSL.swift"; sourceTree = ""; }; + CC000001000000000000AAC0 /* NSColor+Lab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+Lab.swift"; sourceTree = ""; }; EA0C524F25AA729300AFF716 /* Visualisation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Visualisation.swift; sourceTree = ""; }; EA0C525F25AB5A2B00AFF716 /* NavigationMenuItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationMenuItems.swift; sourceTree = ""; }; EA0C526325AB5D1700AFF716 /* PikaWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PikaWindow.swift; sourceTree = ""; }; @@ -336,6 +345,9 @@ EA635DCB25B3B42B0014D91A /* WCAGCompliance.swift */, 22EF1D9A25B7AA18001102FA /* Sequence.swift */, C49A11472DB394F500EE7E80 /* APCACompliance.swift */, + CC000001000000000000AAA0 /* Color+PikaControl.swift */, + CC000001000000000000AAB0 /* NSColor+HSL.swift */, + CC000001000000000000AAC0 /* NSColor+Lab.swift */, ); path = Extensions; sourceTree = ""; @@ -638,6 +650,9 @@ F8ABAC5A2EAAD0DF008CD152 /* ColorPickOverlay.swift in Sources */, EAD0B6F8259CF29300FA2F67 /* AboutView.swift in Sources */, C49A11482DB394F500EE7E80 /* APCACompliance.swift in Sources */, + CC000001000000000000AAA1 /* Color+PikaControl.swift in Sources */, + CC000001000000000000AAB1 /* NSColor+HSL.swift in Sources */, + CC000001000000000000AAC1 /* NSColor+Lab.swift in Sources */, EAA8AE1925B8EC070049299B /* KeyboardShortcutKey.swift in Sources */, EA0C526F25AB683400AFF716 /* EyedropperButton.swift in Sources */, EA72BB8425A5334B008205E7 /* MetalShader.metal in Sources */, @@ -672,6 +687,9 @@ EAE23DAC2D032A38005BB270 /* PikaTouchBar.swift in Sources */, EAE23DAD2D032A38005BB270 /* LoadColors.swift in Sources */, C49A11492DB394F500EE7E80 /* APCACompliance.swift in Sources */, + CC000001000000000000AAA2 /* Color+PikaControl.swift in Sources */, + CC000001000000000000AAB2 /* NSColor+HSL.swift in Sources */, + CC000001000000000000AAC2 /* NSColor+Lab.swift in Sources */, EAE23DAE2D032A38005BB270 /* SplashTouchBar.swift in Sources */, EAE23DAF2D032A38005BB270 /* OverflowContentViewModifier.swift in Sources */, EAE23DB02D032A38005BB270 /* KeyboardShortcutGrid.swift in Sources */, diff --git a/Pika/AppDelegate.swift b/Pika/AppDelegate.swift index 8fbc058a..e2cdb174 100644 --- a/Pika/AppDelegate.swift +++ b/Pika/AppDelegate.swift @@ -351,7 +351,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } preferencesWindow.makeKeyAndOrderFront(nil) preferencesWindow.makeFirstResponder(nil) - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerPreferences), object: self) + notificationCenter.post(name: .triggerPreferences, object: self) } @IBAction func openSplashWindow(_: Any?) { @@ -369,77 +369,77 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } @IBAction func triggerPickForeground(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerPickForeground), object: self) + notificationCenter.post(name: .triggerPickForeground, object: self) } @IBAction func triggerPickBackground(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerPickBackground), object: self) + notificationCenter.post(name: .triggerPickBackground, object: self) } @IBAction func triggerCopyForeground(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerCopyForeground), object: self) + notificationCenter.post(name: .triggerCopyForeground, object: self) } @IBAction func triggerCopyBackground(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerCopyBackground), object: self) + notificationCenter.post(name: .triggerCopyBackground, object: self) } @IBAction func triggerSystemPickerForeground(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerSystemPickerForeground), object: self) + notificationCenter.post(name: .triggerSystemPickerForeground, object: self) } @IBAction func triggerSystemPickerBackground(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerSystemPickerBackground), object: self) + notificationCenter.post(name: .triggerSystemPickerBackground, object: self) } @IBAction func triggerSwap(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerSwap), object: self) + notificationCenter.post(name: .triggerSwap, object: self) } @IBAction func triggerUndo(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerUndo), object: self) + notificationCenter.post(name: .triggerUndo, object: self) undoManager.undo() } @IBAction func triggerRedo(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerRedo), object: self) + notificationCenter.post(name: .triggerRedo, object: self) undoManager.redo() } @IBAction func triggerCopyText(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerCopyText), object: self) + notificationCenter.post(name: .triggerCopyText, object: self) } @IBAction func triggerCopyData(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerCopyData), object: self) + notificationCenter.post(name: .triggerCopyData, object: self) } @IBAction func triggerFormatHex(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerFormatHex), object: self) + notificationCenter.post(name: .triggerFormatHex, object: self) } @IBAction func triggerFormatRGB(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerFormatRGB), object: self) + notificationCenter.post(name: .triggerFormatRGB, object: self) } @IBAction func triggerFormatHSB(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerFormatHSB), object: self) + notificationCenter.post(name: .triggerFormatHSB, object: self) } @IBAction func triggerFormatHSL(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerFormatHSL), object: self) + notificationCenter.post(name: .triggerFormatHSL, object: self) } @IBAction func triggerFormatOpenGL(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerFormatOpenGL), object: self) + notificationCenter.post(name: .triggerFormatOpenGL, object: self) } @IBAction func triggerFormatLAB(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerFormatLAB), object: self) + notificationCenter.post(name: .triggerFormatLAB, object: self) } @IBAction func triggerFormatOKLCH(_: Any) { - notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerFormatOKLCH), object: self) + notificationCenter.post(name: .triggerFormatOKLCH, object: self) } @IBAction func hidePika(_: Any) { diff --git a/Pika/Constants/Constants.swift b/Pika/Constants/Constants.swift index 333d4f9a..b94053b4 100644 --- a/Pika/Constants/Constants.swift +++ b/Pika/Constants/Constants.swift @@ -62,6 +62,29 @@ enum PikaConstants { static let disabledFormats: [ColorFormat] = [.hex, .hsl, .opengl, .lab, .oklch] } +extension Notification.Name { + static let triggerPickForeground = Notification.Name(PikaConstants.ncTriggerPickForeground) + static let triggerPickBackground = Notification.Name(PikaConstants.ncTriggerPickBackground) + static let triggerCopyForeground = Notification.Name(PikaConstants.ncTriggerCopyForeground) + static let triggerCopyBackground = Notification.Name(PikaConstants.ncTriggerCopyBackground) + static let triggerCopyText = Notification.Name(PikaConstants.ncTriggerCopyText) + static let triggerCopyData = Notification.Name(PikaConstants.ncTriggerCopyData) + static let triggerSystemPickerForeground = Notification.Name(PikaConstants.ncTriggerSystemPickerForeground) + static let triggerSystemPickerBackground = Notification.Name(PikaConstants.ncTriggerSystemPickerBackground) + static let triggerSwap = Notification.Name(PikaConstants.ncTriggerSwap) + static let triggerUndo = Notification.Name(PikaConstants.ncTriggerUndo) + static let triggerRedo = Notification.Name(PikaConstants.ncTriggerRedo) + static let triggerPreferences = Notification.Name(PikaConstants.ncTriggerPreferences) + static let triggerFormatHex = Notification.Name(PikaConstants.ncTriggerFormatHex) + static let triggerFormatRGB = Notification.Name(PikaConstants.ncTriggerFormatRGB) + static let triggerFormatHSB = Notification.Name(PikaConstants.ncTriggerFormatHSB) + static let triggerFormatHSL = Notification.Name(PikaConstants.ncTriggerFormatHSL) + static let triggerFormatOpenGL = Notification.Name(PikaConstants.ncTriggerFormatOpenGL) + static let triggerFormatLAB = Notification.Name(PikaConstants.ncTriggerFormatLAB) + static let triggerFormatOKLCH = Notification.Name(PikaConstants.ncTriggerFormatOKLCH) + static let triggerQuit = Notification.Name(PikaConstants.ncTriggerQuit) +} + enum PikaText { static let textAppName = NSLocalizedString("app.name", comment: "Pika") diff --git a/Pika/Extensions/Color+PikaControl.swift b/Pika/Extensions/Color+PikaControl.swift new file mode 100644 index 00000000..8db58394 --- /dev/null +++ b/Pika/Extensions/Color+PikaControl.swift @@ -0,0 +1,9 @@ +import SwiftUI + +extension Color { + static func pikaControlBackground(for colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 27 / 255, green: 27 / 255, blue: 27 / 255) + : Color(red: 233 / 255, green: 233 / 255, blue: 233 / 255) + } +} diff --git a/Pika/Extensions/Cula.swift b/Pika/Extensions/Cula.swift index db87d27d..03f9ed30 100644 --- a/Pika/Extensions/Cula.swift +++ b/Pika/Extensions/Cula.swift @@ -3,8 +3,6 @@ import Defaults import SwiftUI // swiftlint:disable identifier_name -// swiftlint:disable large_tuple -// swiftlint:disable file_length extension NSColor { /* @@ -91,13 +89,11 @@ extension NSColor { func roundToHex(_ x: CGFloat) -> UInt32 { guard x > 0 else { return 0 } let rounded: CGFloat = round(x * 255.0) - return UInt32(rounded) } func toHex() -> UInt32 { let rgba = toRGBAComponents() - return roundToHex(rgba.r) << 16 | roundToHex(rgba.g) << 8 | roundToHex(rgba.b) } @@ -150,7 +146,7 @@ extension NSColor { /** Get the rgb values of this color in 8-bit format. - - returns: An NSColor as an 8-bit rgb string. + - returns: An NSColor as an 8-bit rgb array. */ func toRGB8BitArray() -> [Int] { let RGB = toRGBAComponents() @@ -160,304 +156,6 @@ extension NSColor { return [red, green, blue] } - /* - * OpenGL - */ - - /** - Get the rgb values of this color in opengl format. - - - returns: An NSColor as an opengl string. - */ - func toOpenGLString(style: CopyFormat = .css) -> String { - let RGB = toRGBAComponents() - let red = RGB.r - let green = RGB.g - let blue = RGB.b - - let formatString: NSString - switch style { - case .css, .design, .swiftUI: - formatString = "rgba(%.5g, %.5g, %.5g, 1.0)" - case .unformatted: - formatString = "%.5g, %.5g, %.5g, 1.0" - } - - let openGLString = NSString(format: formatString, red, green, blue) - return openGLString as String - } - - /* - * HSB - */ - - public final func toHSBComponents() -> (h: CGFloat, s: CGFloat, b: CGFloat) { - var h: CGFloat = 0.0 - var s: CGFloat = 0.0 - var b: CGFloat = 0.0 - - guard let rgbaColor = usingColorSpace(Defaults[.colorSpace]) else { - fatalError("Could not convert color to RGBA.") - } - - if toHexString() == NSColor.black.toHexString() { - return (0.0, 0.0, 0.0) - } else if toHexString() == NSColor.white.toHexString() { - return (0.0, 0.0, 1.0) - } - - rgbaColor.getHue(&h, saturation: &s, brightness: &b, alpha: nil) - - return (h: h, s: s, b: b) - } - - /** - Get the hsb values of this color in 8-bit format. - - - returns: An NSColor as an 8-bit hsb string. - */ - func toHSBString(style: CopyFormat = .css) -> String { - let HSB = toHSBComponents() - let hue = Int(round(HSB.h * 360)) - let saturation = Int(round(HSB.s * 100)) - let brightness = Int(round(HSB.b * 100)) - - let hsbString: String - switch style { - case .css: - hsbString = String(format: "hsb(%d, %d%%, %d%%)", hue, saturation, brightness) - case .design: - hsbString = String(format: "hsb(%d, %d, %d)", hue, saturation, brightness) - case .swiftUI: - hsbString = String(format: "Color(hue: %.5g, saturation: %.5g, brightness: %.5g)", HSB.h, HSB.s, HSB.b) - case .unformatted: - hsbString = String(format: "%d, %d, %d", hue, saturation, brightness) - } - return hsbString as String - } - - /* - * HSL - */ - - public final func toHSLComponents() -> (h: CGFloat, s: CGFloat, l: CGFloat) { - var h: CGFloat = 0.0 - var s: CGFloat = 0.0 - var l: CGFloat = 0.0 - - let RGB = toRGBAComponents() - let r = RGB.r - let g = RGB.g - let b = RGB.b - - if toHexString() == NSColor.black.toHexString() { - return (0.0, 0.0, 0.0) - } else if toHexString() == NSColor.white.toHexString() { - return (0.0, 0.0, 1.0) - } - - let min = Swift.min(Swift.min(r, g), b) - let max = Swift.max(Swift.max(r, g), b) - let delta = max - min - - if max == min { - h = 0 - } else if r == max { - h = (g - b) / delta - } else if g == max { - h = 2 + (b - r) / delta - } else { - h = 4 + (r - g) / delta - } - - h = Swift.min(h * 60, 360) - - if h < 0 { - h += 360 - } - - h /= 360 - - l = (min + max) / 2 - - if max == min { - s = 0 - } else if l <= 0.5 { - s = delta / (max + min) - } else { - s = delta / (2 - max - min) - } - - return (h: h, s: s, l: l) - } - - /** - Get the hsl values of this color in 8-bit format. - - - returns: An NSColor as an 8-bit hsl string. - */ - func toHSLString(style: CopyFormat = .css) -> String { - let HSL = toHSLComponents() - let hue = Int(round(HSL.h * 360)) - let saturation = Int(round(HSL.s * 100)) - let lightness = Int(round(HSL.l * 100)) - - let formatString: NSString - switch style { - case .css: - formatString = "hsl(%d, %d%%, %d%%)" - case .design, .swiftUI: - formatString = "hsl(%d, %d, %d)" - case .unformatted: - formatString = "%d, %d, %d" - } - - let hslString = NSString(format: formatString, hue, saturation, lightness) - return hslString as String - } - - /* - * CIE-LAB - */ - - private func toXYZComponents() -> (x: CGFloat, y: CGFloat, z: CGFloat) { - let srgb = toRGBAComponents(in: .sRGB) - - // Linearize sRGB components - func linearize(_ c: CGFloat) -> CGFloat { - if c <= 0.04045 { - return c / 12.92 - } else { - return pow((c + 0.055) / 1.055, 2.4) - } - } - - let r_lin = linearize(srgb.r) - let g_lin = linearize(srgb.g) - let b_lin = linearize(srgb.b) - - // Convert linear RGB to XYZ (D65) - let x = r_lin * 0.4124564 + g_lin * 0.3575761 + b_lin * 0.1804375 - let y = r_lin * 0.2126729 + g_lin * 0.7151522 + b_lin * 0.0721750 - let z = r_lin * 0.0193339 + g_lin * 0.1191920 + b_lin * 0.9503041 - - return (x: x, y: y, z: z) - } - - func toLabComponents() -> (l: CGFloat, a: CGFloat, b: CGFloat) { - let xyz = toXYZComponents() - - // D65 Reference White - let Xn: CGFloat = 0.95047 - let Yn: CGFloat = 1.00000 - let Zn: CGFloat = 1.08883 - - let xr = xyz.x / Xn - let yr = xyz.y / Yn - let zr = xyz.z / Zn - - func f(_ t: CGFloat) -> CGFloat { - let delta: CGFloat = 6.0 / 29.0 - if t > pow(delta, 3.0) { // approx 0.008856 - return pow(t, 1.0 / 3.0) - } else { - return (t / (3.0 * pow(delta, 2.0))) + (4.0 / 29.0) - // Equivalent to: (t * (pow(29.0 / 6.0, 2.0) / 3.0)) + (4.0 / 29.0) - // Or: t * (7.787) + (16.0 / 116.0) - } - } - - let L_star = (116.0 * f(yr)) - 16.0 - let a_star = 500.0 * (f(xr) - f(yr)) - let b_star = 200.0 * (f(yr) - f(zr)) - - return (l: L_star, a: a_star, b: b_star) - } - - func toLabString(style: CopyFormat) -> String { - let lab = toLabComponents() - let l_val = round(lab.l * 100) / 100 - let a_val = round(lab.a * 100) / 100 - let b_val = round(lab.b * 100) / 100 - - let formatString: String - switch style { - case .css: - // CSS L is 0-100, a and b are typically -128 to 127. - // L* from calculation is 0-100. - // For CSS, L is a percentage, but the value is already 0-100. - // The spec actually shows L as a number or percentage, common usage is number. - formatString = String(format: "lab(%.2f %.2f %.2f)", l_val, a_val, b_val) - case .design, .swiftUI: - formatString = String(format: "lab(%.2f, %.2f, %.2f)", l_val, a_val, b_val) - case .unformatted: - formatString = String(format: "%.2f,%.2f,%.2f", l_val, a_val, b_val) - } - return formatString - } - - /* - * OKLCH - */ - - func toOklchComponents() -> (l: CGFloat, c: CGFloat, h: CGFloat) { - let srgb = toRGBAComponents(in: .sRGB) - - // Linearize sRGB components - func linearize(_ c: CGFloat) -> CGFloat { - if c <= 0.04045 { - return c / 12.92 - } else { - return pow((c + 0.055) / 1.055, 2.4) - } - } - - let r_lin = linearize(srgb.r) - let g_lin = linearize(srgb.g) - let b_lin = linearize(srgb.b) - - // Linear RGB to LMS - let l = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin - let m = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin - let s = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin - - // LMS to Oklab (cube root of LMS) - let l_ = cbrt(l) - let m_ = cbrt(m) - let s_ = cbrt(s) - - let L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ - let a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ - let b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ - - // Oklab to OKLCH - let C = sqrt(a * a + b * b) - var H = atan2(b, a) * 180.0 / .pi - if H < 0 { - H += 360.0 - } - - return (l: L, c: C, h: H) - } - - func toOklchString(style: CopyFormat) -> String { - let oklch = toOklchComponents() - let l_val = round(oklch.l * 10000) / 100 - let c_val = round(oklch.c * 10000) / 10000 - let h_val = round(oklch.h * 100) / 100 - - let formatString: String - switch style { - case .css: - formatString = String(format: "oklch(%.2f%% %.4f %.2f)", l_val, c_val, h_val) - case .design, .swiftUI: - formatString = String(format: "oklch(%.2f, %.4f, %.2f)", l_val, c_val, h_val) - case .unformatted: - formatString = String(format: "%.2f, %.4f, %.2f", l_val, c_val, h_val) - } - return formatString - } - /* * Helpers */ @@ -495,6 +193,4 @@ extension NSColor { } } -// swiftlint:enable large_tuple // swiftlint:enable identifier_name -// swiftlint:enable file_length diff --git a/Pika/Extensions/NSColor+HSL.swift b/Pika/Extensions/NSColor+HSL.swift new file mode 100644 index 00000000..1c0699a6 --- /dev/null +++ b/Pika/Extensions/NSColor+HSL.swift @@ -0,0 +1,137 @@ +import Cocoa +import Defaults + +// swiftlint:disable identifier_name + +extension NSColor { + /* + * HSB + */ + + public final func toHSBComponents() -> (h: CGFloat, s: CGFloat, b: CGFloat) { + var h: CGFloat = 0.0 + var s: CGFloat = 0.0 + var b: CGFloat = 0.0 + + guard let rgbaColor = usingColorSpace(Defaults[.colorSpace]) else { + fatalError("Could not convert color to RGBA.") + } + + if toHexString() == NSColor.black.toHexString() { + return (0.0, 0.0, 0.0) + } else if toHexString() == NSColor.white.toHexString() { + return (0.0, 0.0, 1.0) + } + + rgbaColor.getHue(&h, saturation: &s, brightness: &b, alpha: nil) + + return (h: h, s: s, b: b) + } + + /** + Get the hsb values of this color in 8-bit format. + + - returns: An NSColor as an 8-bit hsb string. + */ + func toHSBString(style: CopyFormat = .css) -> String { + let HSB = toHSBComponents() + let hue = Int(round(HSB.h * 360)) + let saturation = Int(round(HSB.s * 100)) + let brightness = Int(round(HSB.b * 100)) + + let hsbString: String + switch style { + case .css: + hsbString = String(format: "hsb(%d, %d%%, %d%%)", hue, saturation, brightness) + case .design: + hsbString = String(format: "hsb(%d, %d, %d)", hue, saturation, brightness) + case .swiftUI: + hsbString = String(format: "Color(hue: %.5g, saturation: %.5g, brightness: %.5g)", HSB.h, HSB.s, HSB.b) + case .unformatted: + hsbString = String(format: "%d, %d, %d", hue, saturation, brightness) + } + return hsbString + } + + /* + * HSL + */ + + public final func toHSLComponents() -> (h: CGFloat, s: CGFloat, l: CGFloat) { + var h: CGFloat = 0.0 + var s: CGFloat = 0.0 + var l: CGFloat = 0.0 + + let RGB = toRGBAComponents() + let r = RGB.r + let g = RGB.g + let b = RGB.b + + if toHexString() == NSColor.black.toHexString() { + return (0.0, 0.0, 0.0) + } else if toHexString() == NSColor.white.toHexString() { + return (0.0, 0.0, 1.0) + } + + let min = Swift.min(Swift.min(r, g), b) + let max = Swift.max(Swift.max(r, g), b) + let delta = max - min + + if max == min { + h = 0 + } else if r == max { + h = (g - b) / delta + } else if g == max { + h = 2 + (b - r) / delta + } else { + h = 4 + (r - g) / delta + } + + h = Swift.min(h * 60, 360) + + if h < 0 { + h += 360 + } + + h /= 360 + + l = (min + max) / 2 + + if max == min { + s = 0 + } else if l <= 0.5 { + s = delta / (max + min) + } else { + s = delta / (2 - max - min) + } + + return (h: h, s: s, l: l) + } + + /** + Get the hsl values of this color in 8-bit format. + + - returns: An NSColor as an 8-bit hsl string. + */ + func toHSLString(style: CopyFormat = .css) -> String { + let HSL = toHSLComponents() + let hue = Int(round(HSL.h * 360)) + let saturation = Int(round(HSL.s * 100)) + let lightness = Int(round(HSL.l * 100)) + + let formatString: NSString + switch style { + case .css: + formatString = "hsl(%d, %d%%, %d%%)" + case .design, .swiftUI: + formatString = "hsl(%d, %d, %d)" + case .unformatted: + formatString = "%d, %d, %d" + } + + let hslString = NSString(format: formatString, hue, saturation, lightness) + return hslString as String + } +} + +// swiftlint:enable identifier_name diff --git a/Pika/Extensions/NSColor+Lab.swift b/Pika/Extensions/NSColor+Lab.swift new file mode 100644 index 00000000..f46c2ad2 --- /dev/null +++ b/Pika/Extensions/NSColor+Lab.swift @@ -0,0 +1,138 @@ +import Cocoa + +// swiftlint:disable identifier_name + +extension NSColor { + // Shared linearization for sRGB components used by both LAB and OKLCH. + private func linearizeSRGB(_ c: CGFloat) -> CGFloat { + c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + + /* + * OpenGL + */ + + /** + Get the rgb values of this color in opengl format. + + - returns: An NSColor as an opengl string. + */ + func toOpenGLString(style: CopyFormat = .css) -> String { + let RGB = toRGBAComponents() + + let formatString: NSString + switch style { + case .css, .design, .swiftUI: + formatString = "rgba(%.5g, %.5g, %.5g, 1.0)" + case .unformatted: + formatString = "%.5g, %.5g, %.5g, 1.0" + } + + let openGLString = NSString(format: formatString, RGB.r, RGB.g, RGB.b) + return openGLString as String + } + + /* + * CIE-LAB + */ + + private func toXYZComponents() -> (x: CGFloat, y: CGFloat, z: CGFloat) { + let srgb = toRGBAComponents(in: .sRGB) + let r_lin = linearizeSRGB(srgb.r) + let g_lin = linearizeSRGB(srgb.g) + let b_lin = linearizeSRGB(srgb.b) + + let x = r_lin * 0.4124564 + g_lin * 0.3575761 + b_lin * 0.1804375 + let y = r_lin * 0.2126729 + g_lin * 0.7151522 + b_lin * 0.0721750 + let z = r_lin * 0.0193339 + g_lin * 0.1191920 + b_lin * 0.9503041 + + return (x: x, y: y, z: z) + } + + func toLabComponents() -> (l: CGFloat, a: CGFloat, b: CGFloat) { + let xyz = toXYZComponents() + + // D65 Reference White + let Xn: CGFloat = 0.95047 + let Yn: CGFloat = 1.00000 + let Zn: CGFloat = 1.08883 + + func f(_ t: CGFloat) -> CGFloat { + let delta: CGFloat = 6.0 / 29.0 + if t > pow(delta, 3.0) { + return pow(t, 1.0 / 3.0) + } else { + return (t / (3.0 * pow(delta, 2.0))) + (4.0 / 29.0) + } + } + + let L_star = (116.0 * f(xyz.y / Yn)) - 16.0 + let a_star = 500.0 * (f(xyz.x / Xn) - f(xyz.y / Yn)) + let b_star = 200.0 * (f(xyz.y / Yn) - f(xyz.z / Zn)) + + return (l: L_star, a: a_star, b: b_star) + } + + func toLabString(style: CopyFormat) -> String { + let lab = toLabComponents() + let l_val = round(lab.l * 100) / 100 + let a_val = round(lab.a * 100) / 100 + let b_val = round(lab.b * 100) / 100 + + switch style { + case .css: + return String(format: "lab(%.2f %.2f %.2f)", l_val, a_val, b_val) + case .design, .swiftUI: + return String(format: "lab(%.2f, %.2f, %.2f)", l_val, a_val, b_val) + case .unformatted: + return String(format: "%.2f,%.2f,%.2f", l_val, a_val, b_val) + } + } + + /* + * OKLCH + */ + + func toOklchComponents() -> (l: CGFloat, c: CGFloat, h: CGFloat) { + let srgb = toRGBAComponents(in: .sRGB) + let r_lin = linearizeSRGB(srgb.r) + let g_lin = linearizeSRGB(srgb.g) + let b_lin = linearizeSRGB(srgb.b) + + let l = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin + let m = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin + let s = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin + + let l_ = cbrt(l) + let m_ = cbrt(m) + let s_ = cbrt(s) + + let L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + let a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + let b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + + let C = sqrt(a * a + b * b) + var H = atan2(b, a) * 180.0 / .pi + if H < 0 { H += 360.0 } + + return (l: L, c: C, h: H) + } + + func toOklchString(style: CopyFormat) -> String { + let oklch = toOklchComponents() + let l_val = round(oklch.l * 10000) / 100 + let c_val = round(oklch.c * 10000) / 10000 + let h_val = round(oklch.h * 100) / 100 + + switch style { + case .css: + return String(format: "oklch(%.2f%% %.4f %.2f)", l_val, c_val, h_val) + case .design, .swiftUI: + return String(format: "oklch(%.2f, %.4f, %.2f)", l_val, c_val, h_val) + case .unformatted: + return String(format: "%.2f, %.4f, %.2f", l_val, c_val, h_val) + } + } +} + +// swiftlint:enable identifier_name diff --git a/Pika/Styles/AppearanceButtonStyle.swift b/Pika/Styles/AppearanceButtonStyle.swift index 37d987f3..d275eac9 100644 --- a/Pika/Styles/AppearanceButtonStyle.swift +++ b/Pika/Styles/AppearanceButtonStyle.swift @@ -1,5 +1,41 @@ import SwiftUI +private let darkBaseColor = Color(red: 0.1, green: 0.1, blue: 0.1) +private let lightBaseColor = Color(red: 0.975, green: 0.975, blue: 0.975) + +@ViewBuilder +private func appearanceSideOverlay(colorScheme: ColorScheme) -> some View { + HStack { + Rectangle() + .fill( + LinearGradient( + gradient: .init( + colors: colorScheme == .dark + ? [darkBaseColor.opacity(0.5), darkBaseColor.opacity(0)] + : [lightBaseColor.opacity(0.9), lightBaseColor.opacity(0)] + ), + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(maxWidth: 25, maxHeight: .infinity) + Spacer() + Rectangle() + .fill( + LinearGradient( + gradient: .init( + colors: colorScheme == .dark + ? [darkBaseColor.opacity(0), darkBaseColor.opacity(0.5)] + : [lightBaseColor.opacity(0), lightBaseColor.opacity(0.9)] + ), + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(maxWidth: 25, maxHeight: .infinity) + } +} + struct AppearanceButtonStyle: ButtonStyle { @Environment(\.colorScheme) var colorScheme: ColorScheme @@ -7,53 +43,6 @@ struct AppearanceButtonStyle: ButtonStyle { var description: String var selected = false - let darkBaseColor = Color(red: 0.1, green: 0.1, blue: 0.1) - let lightBaseColor = Color(red: 0.975, green: 0.975, blue: 0.975) - - func makeOverlay() -> some View { - HStack { - Rectangle() - .fill( - LinearGradient( - gradient: .init( - colors: colorScheme == .dark - ? [ - darkBaseColor.opacity(0.5), - darkBaseColor.opacity(0) - ] - : [ - lightBaseColor.opacity(0.9), - lightBaseColor.opacity(0), - ] - ), - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(maxWidth: 25, maxHeight: .infinity) - Spacer() - Rectangle() - .fill( - LinearGradient( - gradient: .init( - colors: colorScheme == .dark - ? [ - darkBaseColor.opacity(0), - darkBaseColor.opacity(0.5) - ] - : [ - lightBaseColor.opacity(0), - lightBaseColor.opacity(0.9), - ] - ), - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(maxWidth: 25, maxHeight: .infinity) - } - } - func makeBody(configuration: Self.Configuration) -> some View { VStack { configuration.label @@ -73,7 +62,7 @@ struct AppearanceButtonStyle: ButtonStyle { ) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay( - makeOverlay() + appearanceSideOverlay(colorScheme: colorScheme) ) .clipShape( RoundedRectangle(cornerRadius: 10.0, style: .continuous) @@ -110,9 +99,6 @@ struct StyledContentView: View { let description: String let content: Content - let darkBaseColor = Color(red: 0.1, green: 0.1, blue: 0.1) - let lightBaseColor = Color(red: 0.975, green: 0.975, blue: 0.975) - init( title: String, description: String, @ViewBuilder content: () -> Content ) { @@ -121,50 +107,6 @@ struct StyledContentView: View { self.content = content() } - func makeOverlay() -> some View { - HStack { - Rectangle() - .fill( - LinearGradient( - gradient: .init( - colors: colorScheme == .dark - ? [ - darkBaseColor.opacity(0.5), - darkBaseColor.opacity(0) - ] - : [ - lightBaseColor.opacity(0.9), - lightBaseColor.opacity(0), - ] - ), - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(maxWidth: 25, maxHeight: .infinity) - Spacer() - Rectangle() - .fill( - LinearGradient( - gradient: .init( - colors: colorScheme == .dark - ? [ - darkBaseColor.opacity(0), - darkBaseColor.opacity(0.5) - ] - : [ - lightBaseColor.opacity(0), - lightBaseColor.opacity(0.9), - ] - ), - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(maxWidth: 25, maxHeight: .infinity) - } - } - var body: some View { VStack { content @@ -180,7 +122,7 @@ struct StyledContentView: View { ) ) .overlay( - makeOverlay() + appearanceSideOverlay(colorScheme: colorScheme) ) .clipShape( RoundedRectangle(cornerRadius: 10.0, style: .continuous) diff --git a/Pika/Styles/CircleButtonStyle.swift b/Pika/Styles/CircleButtonStyle.swift index 9c799735..afaaf45d 100644 --- a/Pika/Styles/CircleButtonStyle.swift +++ b/Pika/Styles/CircleButtonStyle.swift @@ -1,8 +1,6 @@ -import Combine import SwiftUI struct CircleButtonStyle: ButtonStyle { - @State private var isHovered: Bool = false let isVisible: Bool private struct CircleButtonStyleView: View { @@ -11,15 +9,9 @@ struct CircleButtonStyle: ButtonStyle { let configuration: Configuration let isVisible: Bool - func getBackgroundColor(colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 27 / 255, green: 27 / 255, blue: 27 / 255) - : Color(red: 233 / 255, green: 233 / 255, blue: 233 / 255) - } - var body: some View { let fgColor = colorScheme == .dark ? Color.white : .black - let bgColor: Color = getBackgroundColor(colorScheme: colorScheme) + let bgColor = Color.pikaControlBackground(for: colorScheme) configuration.label .padding(.all, 8) diff --git a/Pika/Styles/SwapButtonStyle.swift b/Pika/Styles/SwapButtonStyle.swift index 10696cfa..6b5a110c 100644 --- a/Pika/Styles/SwapButtonStyle.swift +++ b/Pika/Styles/SwapButtonStyle.swift @@ -1,8 +1,6 @@ -import Combine import SwiftUI struct SwapButtonStyle: ButtonStyle { - @State private var isHovered: Bool = false let isVisible: Bool let alt: String var ltr = false @@ -11,23 +9,16 @@ struct SwapButtonStyle: ButtonStyle { @Environment(\.colorScheme) var colorScheme: ColorScheme @State private var isHovered: Bool = false - @State private var timerSubscription: Cancellable? - @State private var timer = Timer.publish(every: 0.1, on: .main, in: .common) + @State private var hoverTask: Task? let configuration: Configuration let isVisible: Bool let alt: String let ltr: Bool - func getBackgroundColor(colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 27 / 255, green: 27 / 255, blue: 27 / 255) - : Color(red: 233 / 255, green: 233 / 255, blue: 233 / 255) - } - var body: some View { let fgColor = colorScheme == .dark ? Color.white : .black - let bgColor: Color = getBackgroundColor(colorScheme: colorScheme) + let bgColor = Color.pikaControlBackground(for: colorScheme) HStack { if ltr { @@ -67,21 +58,16 @@ struct SwapButtonStyle: ButtonStyle { ) .onHover { hover in if hover { - if timerSubscription == nil { - timer = Timer.publish(every: 0.1, on: .main, in: .common) - timerSubscription = timer.connect() + hoverTask = Task { + try? await Task.sleep(for: .milliseconds(100)) + isHovered = true } } else { - timerSubscription?.cancel() - timerSubscription = nil + hoverTask?.cancel() + hoverTask = nil isHovered = false } } - .onReceive(timer) { _ in - isHovered = true - timerSubscription?.cancel() - timerSubscription = nil - } .opacity(isVisible ? (configuration.isPressed ? 0.8 : 1.0) : 0.0) .foregroundColor(fgColor.opacity(0.8)) .frame(height: 32.0) diff --git a/Pika/Utilities/Eyedroppers.swift b/Pika/Utilities/Eyedroppers.swift index 88b85efc..13b78f57 100644 --- a/Pika/Utilities/Eyedroppers.swift +++ b/Pika/Utilities/Eyedroppers.swift @@ -42,6 +42,27 @@ class Eyedropper: ObservableObject { case .background: return #selector(AppDelegate.triggerSystemPickerBackground) } } + + var pickNotification: Notification.Name { + switch self { + case .foreground: return .triggerPickForeground + case .background: return .triggerPickBackground + } + } + + var copyNotification: Notification.Name { + switch self { + case .foreground: return .triggerCopyForeground + case .background: return .triggerCopyBackground + } + } + + var systemPickerNotification: Notification.Name { + switch self { + case .foreground: return .triggerSystemPickerForeground + case .background: return .triggerSystemPickerBackground + } + } } let type: Types diff --git a/Pika/Views/ComplianceButtons.swift b/Pika/Views/ComplianceButtons.swift index 02e2488e..785e8907 100644 --- a/Pika/Views/ComplianceButtons.swift +++ b/Pika/Views/ComplianceButtons.swift @@ -8,53 +8,35 @@ struct CompliancePreviewWCAG: View { @ObservedObject var background: Eyedropper var body: some View { - let colorWCAGCompliance = foreground.color.toWCAGCompliance( - with: background.color - ) + let wcag = foreground.color.toWCAGCompliance(with: background.color) Button( - action: { - combineCompliance = false - }, + action: { combineCompliance = false }, label: { - ComplianceToggleGroup( - colorCompliance: colorWCAGCompliance, - complianceType: "WCAG", theme: .weight - ) - .padding(20.0) - .frame( - maxWidth: width, maxHeight: .infinity, alignment: .leading - ) + ComplianceToggleGroup(complianceData: .wcag(wcag), theme: .weight) + .padding(20.0) + .frame(maxWidth: width, maxHeight: .infinity, alignment: .leading) } ) - .buttonStyle( - AppearanceButtonStyle( - title: PikaText.textAppearanceWeightTitle, - description: PikaText.textAppearanceWeightDescription, - selected: combineCompliance == false - )) + .buttonStyle(AppearanceButtonStyle( + title: PikaText.textAppearanceWeightTitle, + description: PikaText.textAppearanceWeightDescription, + selected: combineCompliance == false + )) Button( - action: { - combineCompliance = true - }, + action: { combineCompliance = true }, label: { - ComplianceToggleGroup( - colorCompliance: colorWCAGCompliance, - complianceType: "WCAG", theme: .contrast - ) - .padding(20.0) - .frame( - maxWidth: width, maxHeight: .infinity, alignment: .leading - ) + ComplianceToggleGroup(complianceData: .wcag(wcag), theme: .contrast) + .padding(20.0) + .frame(maxWidth: width, maxHeight: .infinity, alignment: .leading) } ) - .buttonStyle( - AppearanceButtonStyle( - title: PikaText.textAppearanceContrastTitle, - description: PikaText.textAppearanceContrastDescription, - selected: combineCompliance == true - )) + .buttonStyle(AppearanceButtonStyle( + title: PikaText.textAppearanceContrastTitle, + description: PikaText.textAppearanceContrastDescription, + selected: combineCompliance == true + )) } } @@ -64,31 +46,20 @@ struct CompliancePreviewAPCA: View { @ObservedObject var background: Eyedropper var body: some View { - let colorAPCACompliance = foreground.color.toAPCACompliance( - with: background.color - ) + let apca = foreground.color.toAPCACompliance(with: background.color) StyledContentView(title: PikaText.textAppearanceAPCATitle, description: PikaText.textAppearanceAPCADescription) { - ComplianceToggleGroup( - colorCompliance: colorAPCACompliance, - complianceType: "APCA", - theme: .weight - ) - .padding(20.0) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + ComplianceToggleGroup(complianceData: .apca(apca), theme: .weight) + .padding(20.0) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } } } struct ComplianceButtons_Previews: PreviewProvider { static var previews: some View { - let foreground = Eyedropper( - type: .foreground, - color: PikaConstants.initialColors.randomElement()! - ) + let foreground = Eyedropper(type: .foreground, color: PikaConstants.initialColors.randomElement()!) let background = Eyedropper(type: .background, color: NSColor.black) - CompliancePreviewWCAG( - width: 200, foreground: foreground, background: background - ) + CompliancePreviewWCAG(width: 200, foreground: foreground, background: background) } } diff --git a/Pika/Views/ComplianceToggleGroup.swift b/Pika/Views/ComplianceToggleGroup.swift index 121e9a44..68819862 100644 --- a/Pika/Views/ComplianceToggleGroup.swift +++ b/Pika/Views/ComplianceToggleGroup.swift @@ -1,21 +1,13 @@ import Defaults import SwiftUI -protocol ColorCompliance { - var complianceType: String { get } -} - -extension NSColor.WCAG: ColorCompliance { - var complianceType: String { "WCAG" } -} - -extension NSColor.APCA: ColorCompliance { - var complianceType: String { "APCA" } +enum ComplianceData { + case wcag(NSColor.WCAG) + case apca(NSColor.APCA) } struct ComplianceToggleGroup: View { - var colorCompliance: Any - var complianceType: String + var complianceData: ComplianceData var size = Sizes.full var theme: Themes @@ -30,130 +22,45 @@ struct ComplianceToggleGroup: View { } var body: some View { - if complianceType == "WCAG", let wcag = colorCompliance as? NSColor.WCAG { + switch complianceData { + case let .wcag(wcag): if theme == .weight { HStack(spacing: size == .full ? 16.0 : 8.0) { HStack(alignment: .center, spacing: 8.0) { if size == .full { Text(PikaText.textColorNormal) .fontWeight(.semibold) - .foregroundColor( - (wcag.ratio45 && wcag.ratio70) - ? .primary - : .secondary - ) + .foregroundColor((wcag.ratio45 && wcag.ratio70) ? .primary : .secondary) .fixedSize() } - - ComplianceToggle( - title: "AA", - isCompliant: wcag.ratio45, - tooltip: PikaText.textColorWCAG45, - size: size - ) - ComplianceToggle( - title: "AAA", - isCompliant: wcag.ratio70, - tooltip: PikaText.textColorWCAG70, - size: size - ) + ComplianceToggle(title: "AA", isCompliant: wcag.ratio45, tooltip: PikaText.textColorWCAG45, size: size) + ComplianceToggle(title: "AAA", isCompliant: wcag.ratio70, tooltip: PikaText.textColorWCAG70, size: size) } HStack(alignment: .center, spacing: 8.0) { if size == .full { Text(PikaText.textColorLarge) .fontWeight(.semibold) - .foregroundColor( - (wcag.ratio30 && wcag.ratio45) - ? .primary - : .secondary - ) + .foregroundColor((wcag.ratio30 && wcag.ratio45) ? .primary : .secondary) .fixedSize() } - - ComplianceToggle( - title: "AA", - isCompliant: wcag.ratio30, - tooltip: PikaText.textColorWCAG30, - large: true, - size: size - ) - ComplianceToggle( - title: "AAA", - isCompliant: wcag.ratio45, - tooltip: PikaText.textColorWCAG45, - large: true, - size: size - ) + ComplianceToggle(title: "AA", isCompliant: wcag.ratio30, tooltip: PikaText.textColorWCAG30, large: true, size: size) + ComplianceToggle(title: "AAA", isCompliant: wcag.ratio45, tooltip: PikaText.textColorWCAG45, large: true, size: size) } } - } else if theme == .contrast, - let wcag = colorCompliance as? NSColor.WCAG - { + } else { HStack(spacing: size == .full ? 16.0 : 8.0) { - ComplianceToggle( - title: "AA", - isCompliant: wcag.ratio30, - tooltip: NSLocalizedString( - "color.wcag.30", comment: "WCAG 3:1" - ), - large: true, - combined: true, - size: size - ) - ComplianceToggle( - title: "AA/AAA", - isCompliant: wcag.ratio45, - tooltip: NSLocalizedString( - "color.wcag.45", comment: "WCAG 4.5:1" - ), - large: true, - combined: true, - size: size - ) - ComplianceToggle( - title: "AAA", - isCompliant: wcag.ratio70, - tooltip: NSLocalizedString( - "color.wcag.70", comment: "WCAG 7:1" - ), - combined: true, - size: size - ) + ComplianceToggle(title: "AA", isCompliant: wcag.ratio30, tooltip: PikaText.textColorWCAG30, large: true, combined: true, size: size) + ComplianceToggle(title: "AA/AAA", isCompliant: wcag.ratio45, tooltip: PikaText.textColorWCAG45, large: true, combined: true, size: size) + ComplianceToggle(title: "AAA", isCompliant: wcag.ratio70, tooltip: PikaText.textColorWCAG70, combined: true, size: size) } } - } else if complianceType == "APCA", - let apca = colorCompliance as? NSColor.APCA - { + case let .apca(apca): HStack(spacing: size == .full ? 16.0 : 8.0) { - ComplianceToggle( - title: PikaText.textAPCABaseline, - isCompliant: abs(apca.value) >= 30, - tooltip: PikaText.textColorAPCA30, - combined: true, - size: size - ) - ComplianceToggle( - title: PikaText.textAPCAHeadline, - isCompliant: abs(apca.value) >= 45, - tooltip: PikaText.textColorAPCA45, - combined: true, - size: size - ) - ComplianceToggle( - title: PikaText.textAPCATitle, - isCompliant: abs(apca.value) >= 60, - tooltip: PikaText.textColorAPCA60, - combined: true, - size: size - ) - ComplianceToggle( - title: PikaText.textAPCABody, - isCompliant: abs(apca.value) >= 75, - tooltip: PikaText.textColorAPCA75, - combined: true, - size: size - ) + ComplianceToggle(title: PikaText.textAPCABaseline, isCompliant: abs(apca.value) >= 30, tooltip: PikaText.textColorAPCA30, combined: true, size: size) + ComplianceToggle(title: PikaText.textAPCAHeadline, isCompliant: abs(apca.value) >= 45, tooltip: PikaText.textColorAPCA45, combined: true, size: size) + ComplianceToggle(title: PikaText.textAPCATitle, isCompliant: abs(apca.value) >= 60, tooltip: PikaText.textColorAPCA60, combined: true, size: size) + ComplianceToggle(title: PikaText.textAPCABody, isCompliant: abs(apca.value) >= 75, tooltip: PikaText.textColorAPCA75, combined: true, size: size) } } } @@ -163,20 +70,12 @@ struct ComplianceToggleGroup_Previews: PreviewProvider { static var previews: some View { Group { let wcag = NSColor.white.WCAGCompliance(with: NSColor.red) - ComplianceToggleGroup( - colorCompliance: wcag, - complianceType: "WCAG", - theme: .contrast - ) - .frame(width: 200, height: 18) + ComplianceToggleGroup(complianceData: .wcag(wcag), theme: .contrast) + .frame(width: 200, height: 18) let apca = NSColor.white.APCACompliance(with: NSColor.red) - ComplianceToggleGroup( - colorCompliance: apca, - complianceType: "APCA", - theme: .contrast - ) - .frame(width: 200, height: 18) + ComplianceToggleGroup(complianceData: .apca(apca), theme: .contrast) + .frame(width: 200, height: 18) } } } diff --git a/Pika/Views/ContentView.swift b/Pika/Views/ContentView.swift index ce400ad8..47e33d7b 100644 --- a/Pika/Views/ContentView.swift +++ b/Pika/Views/ContentView.swift @@ -1,4 +1,3 @@ -import Combine import Defaults import SwiftUI @@ -11,8 +10,7 @@ struct ContentView: View { let pasteboard = NSPasteboard.general @State var swapVisible: Bool = false - @State private var timerSubscription: Cancellable? - @State private var timer = Timer.publish(every: 0.25, on: .main, in: .common) + @State private var swapHideTask: Task? @State private var angle: Double = 0 var body: some View { @@ -20,15 +18,16 @@ struct ContentView: View { Divider() ColorPickers() .onHover { hover in - guard hover else { return } - swapVisible = true - timerSubscription?.cancel() - timerSubscription = nil - } - .onReceive(timer) { _ in - swapVisible = false - timerSubscription?.cancel() - timerSubscription = nil + if hover { + swapHideTask?.cancel() + swapHideTask = nil + swapVisible = true + } else { + swapHideTask = Task { + try? await Task.sleep(for: .milliseconds(250)) + swapVisible = false + } + } } .overlay( Button(action: { @@ -44,22 +43,13 @@ struct ContentView: View { alt: PikaText.textColorSwap, ltr: true )) - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name(PikaConstants.ncTriggerSwap))) - { _ in + .onReceive(NotificationCenter.default.publisher(for: .triggerSwap)) { _ in swap(&eyedroppers.foreground.color, &eyedroppers.background.color) } .focusable(false) .padding(16.0) .frame(maxHeight: .infinity, alignment: .top) ) - .onHover { hover in - guard !hover, timerSubscription == nil else { - return - } - timer = Timer.publish(every: 0.25, on: .main, in: .common) - timerSubscription = timer.connect() - } Divider() Footer(foreground: eyedroppers.foreground, background: eyedroppers.background) @@ -69,22 +59,14 @@ struct ContentView: View { ? NSColor.white : NSColor.black } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name(PikaConstants.ncTriggerCopyText))) - { _ in + .onReceive(NotificationCenter.default.publisher(for: .triggerCopyText)) { _ in pasteboard.clearContents() - // swiftlint:disable line_length let contents = "\(Exporter.toText(foreground: eyedroppers.foreground, background: eyedroppers.background, style: copyFormat))" - // swiftlint:enable line_length pasteboard.setString(contents, forType: .string) } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name(PikaConstants.ncTriggerCopyData))) - { _ in + .onReceive(NotificationCenter.default.publisher(for: .triggerCopyData)) { _ in pasteboard.clearContents() - // swiftlint:disable line_length let contents = "\(Exporter.toJSON(foreground: eyedroppers.foreground, background: eyedroppers.background, style: copyFormat))" - // swiftlint:enable line_length pasteboard.setString(contents, forType: .string) } } diff --git a/Pika/Views/EyedropperButton.swift b/Pika/Views/EyedropperButton.swift index 5702f62b..735979e0 100644 --- a/Pika/Views/EyedropperButton.swift +++ b/Pika/Views/EyedropperButton.swift @@ -1,4 +1,3 @@ -import Combine import Defaults import SwiftUI @@ -9,8 +8,7 @@ struct EyedropperButton: View { @Default(.hideColorNames) var hideColorNames @State var hoverVisible: Bool = false - @State private var timerSubscription: Cancellable? - @State private var timer = Timer.publish(every: 0.25, on: .main, in: .common) + @State private var hoverTask: Task? var body: some View { ZStack { @@ -84,21 +82,16 @@ struct EyedropperButton: View { } .onHover { hover in if hover { + hoverTask?.cancel() + hoverTask = nil hoverVisible = true - timerSubscription?.cancel() - timerSubscription = nil } else { - if timerSubscription == nil { - timer = Timer.publish(every: 0.25, on: .main, in: .common) - timerSubscription = timer.connect() + hoverTask = Task { + try? await Task.sleep(for: .milliseconds(250)) + hoverVisible = false } } } - .onReceive(timer) { _ in - hoverVisible = false - timerSubscription?.cancel() - timerSubscription = nil - } } } diff --git a/Pika/Views/EyedropperItem.swift b/Pika/Views/EyedropperItem.swift index a1555f66..8d65348f 100644 --- a/Pika/Views/EyedropperItem.swift +++ b/Pika/Views/EyedropperItem.swift @@ -19,87 +19,66 @@ struct EyedropperItem: View { ZStack { EyedropperButton(eyedropper: eyedropper) .frame(maxWidth: .infinity, maxHeight: .infinity) - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name("triggerPick\(eyedropper.type.rawValue.capitalized)"))) - { _ in - eyedropper.start() - } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name("triggerCopy\(eyedropper.type.rawValue.capitalized)"))) - { _ in - showToast = true - pasteboard.clearContents() - let contents - = "\(eyedropper.color.toFormat(format: colorFormat, style: Defaults[.copyFormat]))" - pasteboard.setString(contents, forType: .string) - } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name("triggerSystemPicker\(eyedropper.type.rawValue.capitalized)"))) - { _ in - let panel = NSColorPanel.shared - if panel.isVisible, panel.title == "\(eyedropper.type.rawValue.capitalized)" { - panel.close() - } else { - eyedropper.picker() + .onReceive(NotificationCenter.default.publisher(for: eyedropper.type.pickNotification)) { _ in + eyedropper.start() } - } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name("triggerFormatHex"))) - { _ in - if copyFormat != .swiftUI { - colorFormat = ColorFormat.hex + .onReceive(NotificationCenter.default.publisher(for: eyedropper.type.copyNotification)) { _ in + showToast = true + pasteboard.clearContents() + let contents = "\(eyedropper.color.toFormat(format: colorFormat, style: Defaults[.copyFormat]))" + pasteboard.setString(contents, forType: .string) } - } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name("triggerFormatRGB"))) - { _ in - colorFormat = ColorFormat.rgb - } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name("triggerFormatHSB"))) - { _ in - colorFormat = ColorFormat.hsb - } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name("triggerFormatHSL"))) - { _ in - if copyFormat != .swiftUI { - colorFormat = ColorFormat.hsl + .onReceive(NotificationCenter.default.publisher(for: eyedropper.type.systemPickerNotification)) { _ in + let panel = NSColorPanel.shared + if panel.isVisible, panel.title == "\(eyedropper.type.rawValue.capitalized)" { + panel.close() + } else { + eyedropper.picker() + } + } + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatHex)) { _ in + if copyFormat != .swiftUI { + colorFormat = ColorFormat.hex + } + } + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatRGB)) { _ in + colorFormat = ColorFormat.rgb + } + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatHSB)) { _ in + colorFormat = ColorFormat.hsb } - } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name("triggerFormatOpenGL"))) - { _ in - if copyFormat != .swiftUI { - colorFormat = ColorFormat.opengl + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatHSL)) { _ in + if copyFormat != .swiftUI { + colorFormat = ColorFormat.hsl + } } - } - .onChange(of: copyFormat) { - if copyFormat == .swiftUI { - if PikaConstants.disabledFormats.contains(colorFormat) { - colorFormat = .rgb + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatOpenGL)) { _ in + if copyFormat != .swiftUI { + colorFormat = ColorFormat.opengl } } - } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name(PikaConstants.ncTriggerFormatLAB))) - { _ in - if copyFormat != .swiftUI { - colorFormat = .lab + .onChange(of: copyFormat) { + if copyFormat == .swiftUI { + if PikaConstants.disabledFormats.contains(colorFormat) { + colorFormat = .rgb + } + } } - } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name(PikaConstants.ncTriggerFormatOKLCH))) - { _ in - if copyFormat != .swiftUI { - colorFormat = .oklch + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatLAB)) { _ in + if copyFormat != .swiftUI { + colorFormat = .lab + } + } + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatOKLCH)) { _ in + if copyFormat != .swiftUI { + colorFormat = .oklch + } } - } - .toast( - isShowing: $showToast, - color: eyedropper.color.getUIColor(), - text: Text(String(PikaText.textColorCopied)) - ) + .toast( + isShowing: $showToast, + color: eyedropper.color.getUIColor(), + text: Text(String(PikaText.textColorCopied)) + ) } } } diff --git a/Pika/Views/Footer.swift b/Pika/Views/Footer.swift index e4db2422..044fc146 100644 --- a/Pika/Views/Footer.swift +++ b/Pika/Views/Footer.swift @@ -7,15 +7,27 @@ struct Footer: View { @ObservedObject var foreground: Eyedropper @ObservedObject var background: Eyedropper - var body: some View { - let colorContrastRatio = contrastStandard == .wcag ? foreground.color.toContrastRatioString(with: background.color) : - foreground.color.toAPCAcontrastValue(with: background.color) - let complianceType = contrastStandard == .wcag ? "WCAG" : "APCA" - let colorCompliance: Any = contrastStandard == .wcag ? - foreground.color.toWCAGCompliance(with: background.color) as Any : - foreground.color.toAPCACompliance(with: background.color) as Any - let contrastHeader = contrastStandard == .wcag ? PikaText.textColorRatio : PikaText.textLightnessContrastValue + private var contrastRatioString: String { + contrastStandard == .wcag + ? foreground.color.toContrastRatioString(with: background.color) + : foreground.color.toAPCAcontrastValue(with: background.color) + } + private var complianceData: ComplianceData { + contrastStandard == .wcag + ? .wcag(foreground.color.toWCAGCompliance(with: background.color)) + : .apca(foreground.color.toAPCACompliance(with: background.color)) + } + + private var contrastHeader: String { + contrastStandard == .wcag ? PikaText.textColorRatio : PikaText.textLightnessContrastValue + } + + private var complianceLabel: String { + contrastStandard == .wcag ? PikaText.textColorWCAG : PikaText.textColorAPCA + } + + var body: some View { HStack(spacing: 16.0) { VStack(alignment: .leading, spacing: 0.0) { Text(contrastHeader) @@ -25,7 +37,7 @@ struct Footer: View { .fixedSize() if contrastStandard == .wcag { HStack(spacing: 2.0) { - Text("\(colorContrastRatio)") + Text(contrastRatioString) .font(.system(size: 18)) .help(PikaText.textColorRatioDescription) .fixedSize() @@ -41,7 +53,7 @@ struct Footer: View { } } } else { - Text("\(colorContrastRatio)") + Text(contrastRatioString) .font(.system(size: 18)) .help(PikaText.textLightnessContrastValueDescription) .fixedSize() @@ -51,14 +63,13 @@ struct Footer: View { Divider() VStack(alignment: .leading, spacing: 3.0) { - Text(contrastStandard == .wcag ? PikaText.textColorWCAG : PikaText.textColorAPCA) + Text(complianceLabel) .font(.caption) .fontWeight(.semibold) .foregroundColor(.secondary) ComplianceToggleGroup( - colorCompliance: colorCompliance, - complianceType: complianceType, + complianceData: complianceData, theme: combineCompliance ? .contrast : .weight ) } diff --git a/Pika/Views/KeyboardShortcutGrid.swift b/Pika/Views/KeyboardShortcutGrid.swift index 10753f94..c929a924 100644 --- a/Pika/Views/KeyboardShortcutGrid.swift +++ b/Pika/Views/KeyboardShortcutGrid.swift @@ -1,5 +1,65 @@ import SwiftUI +private struct ShortcutEntry { + let title: String + let notificationName: Notification.Name + let keys: [String] +} + +private let pickRow: [ShortcutEntry] = [ + ShortcutEntry(title: PikaText.textPickForeground, notificationName: .triggerPickForeground, keys: ["⌘", "D"]), + ShortcutEntry(title: PikaText.textPickBackground, notificationName: .triggerPickBackground, keys: ["⇧", "⌘", "D"]), + ShortcutEntry(title: PikaText.textCopyForeground, notificationName: .triggerCopyForeground, keys: ["⌘", "C"]), + ShortcutEntry(title: PikaText.textCopyBackground, notificationName: .triggerCopyBackground, keys: ["⇧", "⌘", "C"]), + ShortcutEntry(title: PikaText.textColorSystemPickerForegroundSimple, notificationName: .triggerSystemPickerForeground, keys: ["⌘", "S"]), + ShortcutEntry(title: PikaText.textColorSystemPickerBackgroundSimple, notificationName: .triggerSystemPickerBackground, keys: ["⇧", "⌘", "S"]), +] + +private let actionRow: [ShortcutEntry] = [ + ShortcutEntry(title: PikaText.textColorUndo, notificationName: .triggerUndo, keys: ["⌘", "z"]), + ShortcutEntry(title: PikaText.textColorRedo, notificationName: .triggerRedo, keys: ["⇧", "⌘", "Z"]), + ShortcutEntry(title: PikaText.textColorSwapDetail, notificationName: .triggerSwap, keys: ["X"]), + ShortcutEntry(title: "\(PikaText.textMenuPreferences)...", notificationName: .triggerPreferences, keys: ["⌘", ","]), + ShortcutEntry(title: PikaText.textMenuQuit, notificationName: .triggerQuit, keys: ["⌘", "Q"]), +] + +private let formatRow: [ShortcutEntry] = [ + ShortcutEntry(title: PikaText.textFormatHex, notificationName: .triggerFormatHex, keys: ["⌘", "1"]), + ShortcutEntry(title: PikaText.textFormatRGB, notificationName: .triggerFormatRGB, keys: ["⌘", "2"]), + ShortcutEntry(title: PikaText.textFormatHSB, notificationName: .triggerFormatHSB, keys: ["⌘", "3"]), + ShortcutEntry(title: PikaText.textFormatHSL, notificationName: .triggerFormatHSL, keys: ["⌘", "4"]), + ShortcutEntry(title: PikaText.textFormatLAB, notificationName: .triggerFormatLAB, keys: ["⌘", "5"]), + ShortcutEntry(title: PikaText.textFormatOpenGL, notificationName: .triggerFormatOpenGL, keys: ["⌘", "6"]), + ShortcutEntry(title: PikaText.textFormatOKLCH, notificationName: .triggerFormatOKLCH, keys: ["⌘", "7"]), +] + +private struct ShortcutRow: View { + let entries: [ShortcutEntry] + let unitWidth: CGFloat + let unitHeight: CGFloat + var padWithSpacer = false + + var body: some View { + HStack(spacing: 0) { + ForEach(entries.indices, id: \.self) { index in + if index > 0 { + Divider().frame(height: unitHeight) + } + KeyboardShortcutItem( + title: entries[index].title, + notificationName: entries[index].notificationName, + keys: entries[index].keys + ) + .frame(width: unitWidth, height: unitHeight) + } + if padWithSpacer { + Divider().frame(height: unitHeight) + Spacer() + } + } + } +} + struct KeyboardShortcutGrid: View { var body: some View { VStack(spacing: 0) { @@ -8,200 +68,19 @@ struct KeyboardShortcutGrid: View { GeometryReader { geometry in let width = geometry.size.width let height = geometry.size.height - - let horizontalUnit = width / 7 - let verticalUnit = floor(height / 3) + let unitWidth = width / 7 + let unitHeight = floor(height / 3) VStack(spacing: 0.0) { - // Pick - HStack(spacing: 0.0) { - KeyboardShortcutItem( - title: PikaText.textPickForeground, - event: PikaConstants.ncTriggerPickForeground, - keys: ["⌘", "D"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textPickBackground, - event: PikaConstants.ncTriggerPickBackground, - keys: ["⇧", "⌘", "D"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textCopyForeground, - event: PikaConstants.ncTriggerCopyForeground, - keys: ["⌘", "C"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textCopyBackground, - event: PikaConstants.ncTriggerCopyBackground, - keys: ["⇧", "⌘", "C"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textColorSystemPickerForegroundSimple, - event: PikaConstants.ncTriggerSystemPickerForeground, - keys: ["⌘", "S"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textColorSystemPickerBackgroundSimple, - event: PikaConstants.ncTriggerSystemPickerBackground, - keys: ["⇧", "⌘", "S"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - } - - Divider() - .frame(maxWidth: .infinity) - - // Copy - HStack(spacing: 0) { - KeyboardShortcutItem( - title: PikaText.textColorUndo, - event: PikaConstants.ncTriggerUndo, - keys: ["⌘", "z"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textColorRedo, - event: PikaConstants.ncTriggerRedo, - keys: ["⇧", "⌘", "Z"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textColorSwapDetail, - event: PikaConstants.ncTriggerSwap, - keys: ["X"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: "\(PikaText.textMenuPreferences)...", - event: PikaConstants.ncTriggerPreferences, - keys: ["⌘", ","] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textMenuQuit, - event: PikaConstants.ncTriggerQuit, - keys: ["⌘", "Q"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - Spacer() - } - - Divider() - .frame(maxWidth: .infinity) - - HStack(spacing: 0) { - KeyboardShortcutItem( - title: PikaText.textFormatHex, - event: PikaConstants.ncTriggerFormatHex, - keys: ["⌘", "1"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textFormatRGB, - event: PikaConstants.ncTriggerFormatRGB, - keys: ["⌘", "2"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textFormatHSB, - event: PikaConstants.ncTriggerFormatHSB, - keys: ["⌘", "3"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textFormatHSL, - event: PikaConstants.ncTriggerFormatHSL, - keys: ["⌘", "4"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - - Divider() - .frame(height: verticalUnit) - - KeyboardShortcutItem( - title: PikaText.textFormatLAB, - event: PikaConstants.ncTriggerFormatLAB, - keys: ["⌘", "5"] - ) - .frame(width: horizontalUnit, height: verticalUnit) + ShortcutRow(entries: pickRow, unitWidth: unitWidth, unitHeight: unitHeight) - Divider() - .frame(height: verticalUnit) + Divider().frame(maxWidth: .infinity) - KeyboardShortcutItem( - title: PikaText.textFormatOpenGL, - event: PikaConstants.ncTriggerFormatOpenGL, - keys: ["⌘", "6"] - ) - .frame(width: horizontalUnit, height: verticalUnit) + ShortcutRow(entries: actionRow, unitWidth: unitWidth, unitHeight: unitHeight, padWithSpacer: true) - Divider() - .frame(height: verticalUnit) + Divider().frame(maxWidth: .infinity) - KeyboardShortcutItem( - title: PikaText.textFormatOKLCH, - event: PikaConstants.ncTriggerFormatOKLCH, - keys: ["⌘", "7"] - ) - .frame(width: horizontalUnit, height: verticalUnit) - } + ShortcutRow(entries: formatRow, unitWidth: unitWidth, unitHeight: unitHeight) } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/Pika/Views/KeyboardShortcutItem.swift b/Pika/Views/KeyboardShortcutItem.swift index b87e367b..43208f43 100644 --- a/Pika/Views/KeyboardShortcutItem.swift +++ b/Pika/Views/KeyboardShortcutItem.swift @@ -2,9 +2,9 @@ import SwiftUI struct KeyboardShortcutItem: View { @Environment(\.colorScheme) var colorScheme: ColorScheme - @State var title: String - @State var event: String - @State var keys: [String] + let title: String + let notificationName: Notification.Name + let keys: [String] @State private var highlight: Bool = false var body: some View { @@ -21,11 +21,10 @@ struct KeyboardShortcutItem: View { } } } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name(event))) - { _ in + .onReceive(NotificationCenter.default.publisher(for: notificationName)) { _ in highlight = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + Task { + try? await Task.sleep(for: .milliseconds(600)) withAnimation { highlight = false } @@ -41,7 +40,7 @@ struct KeyboardShortcutItem: View { struct KeyboardShortcutItem_Previews: PreviewProvider { static var previews: some View { KeyboardShortcutItem(title: "Copy example but with a much longer text", - event: PikaConstants.ncTriggerPickForeground, keys: ["A", "B"]) + notificationName: .triggerPickForeground, keys: ["A", "B"]) .frame(width: 100.0, height: 100.0) } } diff --git a/Pika/Views/PreferencesView.swift b/Pika/Views/PreferencesView.swift index 6b9f209e..8e5de079 100644 --- a/Pika/Views/PreferencesView.swift +++ b/Pika/Views/PreferencesView.swift @@ -3,47 +3,204 @@ import KeyboardShortcuts import LaunchAtLogin import SwiftUI -struct PreferencesView: View { +// MARK: - Sub-views + +private struct GeneralAndSelectionSection: View { @Default(.hideMenuBarIcon) var hideMenuBarIcon @Default(.hideColorNames) var hideColorNames #if TARGET_SPARKLE @Default(.betaUpdates) var betaUpdates #endif @Default(.hidePikaWhilePicking) var hidePikaWhilePicking - @Default(.copyColorOnPick) var copyColorOnPick - @Default(.copyFormat) var copyFormat @Default(.appMode) var appMode @Default(.appFloating) var appFloating @Default(.alwaysShowOnLaunch) var alwaysShowOnLaunch - @Default(.contrastStandard) var contrastStandard @Default(.showColorOverlay) var showColorOverlay @Default(.colorOverlayDuration) var colorOverlayDuration - @State var colorSpace: NSColorSpace = Defaults[.colorSpace] @State var disableHideMenuBarIcon = true - @State private var viewHeight: CGFloat = 0.0 + var body: some View { + HStack(alignment: .top, spacing: 0) { + VStack(alignment: .leading, spacing: 10.0) { + Text(PikaText.textGeneralTitle).font(.system(size: 16)) + LaunchAtLogin.Toggle { + Text(PikaText.textLaunchDescription) + } + #if TARGET_SPARKLE + Toggle(isOn: $betaUpdates) { + Text(PikaText.textBetaDescription) + } + #endif + Toggle(isOn: $alwaysShowOnLaunch) { + Text(PikaText.textAlwaysShowOnLaunch) + } + #if TARGET_SPARKLE + .onReceive([betaUpdates].publisher.first()) { _ in + NSApp.sendAction( + #selector(AppDelegate.updateFeedURL), + to: nil, from: nil + ) + } + #endif + if appMode != .menubar { + Toggle(isOn: $disableHideMenuBarIcon) { + Text(PikaText.textIconDescription) + }.disabled(true) + } else { + Toggle(isOn: $hideMenuBarIcon) { + Text(PikaText.textIconDescription) + } + } + } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .padding(.all, 24.0) + + Divider() + + VStack(alignment: .leading, spacing: 10.0) { + Text(PikaText.textSelectionTitle).font(.system(size: 16)) + Toggle(isOn: $hidePikaWhilePicking) { + Text(PikaText.textPickHide) + } + Toggle(isOn: $hideColorNames) { + Text(PikaText.textColorNamesDescription) + } + Toggle(isOn: $appFloating) { + Text(PikaText.textFloatDescription) + .frame(maxWidth: .infinity, alignment: .leading) + } + Toggle(isOn: $showColorOverlay) { + Text(PikaText.textShowColorOverlay) + } + if showColorOverlay { + HStack(spacing: 8.0) { + Text(PikaText.textDuration) + .font(.system(size: 12)) + Slider(value: $colorOverlayDuration, in: 1.0 ... 5.0, step: 0.5) + Text(String(format: "%.1fs", colorOverlayDuration)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .padding(.leading, 20.0) + } + } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .padding(.all, 24.0) + } + .fixedSize(horizontal: false, vertical: true) + } +} + +private struct AppModeSection: View { + var body: some View { + VStack(alignment: .leading, spacing: 10.0) { + Text(PikaText.textAppTitle).font(.system(size: 16)) + GeometryReader { geometry in + let width = geometry.size.width + HStack(spacing: 16.0) { + AppModeButtons(width: width / 2 - 8) + } + .frame(maxWidth: width) + } + .frame(height: 96) + } + .padding(.horizontal, 24.0) + } +} + +private struct AppearanceSection: View { + @Default(.contrastStandard) var contrastStandard @EnvironmentObject var eyedroppers: Eyedroppers + var body: some View { + VStack(alignment: .leading, spacing: 10.0) { + Text(PikaText.textAppearanceTitle).font(.system(size: 16)) + HStack(spacing: 16.0) { + Picker(PikaText.textContrastStandard, selection: $contrastStandard) { + ForEach(ContrastStandard.allCases, id: \.self) { value in + Text(value.rawValue) + } + } + .pickerStyle(RadioGroupPickerStyle()) + .horizontalRadioGroupLayout() + .fixedSize() + } + .padding(.bottom, 8.0) + if contrastStandard == .wcag { + GeometryReader { geometry in + let width = geometry.size.width + HStack(spacing: 16.0) { + CompliancePreviewWCAG( + width: width / 2 - 8, + foreground: eyedroppers.foreground, + background: eyedroppers.background + ) + } + .frame(maxWidth: width) + } + .frame(height: 100) + } else { + GeometryReader { geometry in + CompliancePreviewAPCA( + foreground: eyedroppers.foreground, + background: eyedroppers.background + ) + .frame(maxWidth: geometry.size.width) + } + .frame(height: 100) + } + } + .padding(.horizontal, 24.0) + } +} + +private struct CopySettingsSection: View { + @Default(.copyFormat) var copyFormat + @Default(.copyColorOnPick) var copyColorOnPick + @EnvironmentObject var eyedroppers: Eyedroppers + + var body: some View { + VStack(alignment: .leading, spacing: 10.0) { + Text(PikaText.textCopyTitle).font(.system(size: 16)) + VStack(alignment: .leading, spacing: 12.0) { + HStack(alignment: .firstTextBaseline, spacing: 8.0) { + Text(PikaText.textCopyExport).fixedSize() + Picker(PikaText.textCopyFormat, selection: $copyFormat) { + ForEach(CopyFormat.allCases, id: \.self) { value in + Text(value.localizedString()) + } + } + .pickerStyle(.menu) + .labelsHidden() + } + ColorExampleRow(copyFormat: copyFormat, eyedropper: eyedroppers.foreground) + } + Toggle(isOn: $copyColorOnPick) { + Text(PikaText.textCopyAutomatic) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 24.0) + } +} + +private struct ColorFormatSection: View { + @State var colorSpace: NSColorSpace = Defaults[.colorSpace] + // swiftlint:disable large_tuple opening_brace func getColorSpaces() -> ([NSColorSpace], [NSColorSpace], NSColorSpace) { let systemDefaultSpace: NSColorSpace = NSScreen.main!.colorSpace! - var availableSpaces = NSColorSpace.availableColorSpaces(with: .rgb) - .unique() + var availableSpaces = NSColorSpace.availableColorSpaces(with: .rgb).unique() if !availableSpaces.contains(systemDefaultSpace) { availableSpaces.append(systemDefaultSpace) } var primarySpaces: [NSColorSpace] = [] - for space in availableSpaces { if space == NSColorSpace.sRGB || space == NSColorSpace.adobeRGB1998 - || space == NSColorSpace.displayP3 - || space == systemDefaultSpace + || space == NSColorSpace.displayP3 || space == systemDefaultSpace { - guard let index = availableSpaces.firstIndex(of: space) else { - continue - } + guard let index = availableSpaces.firstIndex(of: space) else { continue } availableSpaces.remove(at: index) - if space == systemDefaultSpace { primarySpaces.insert(space, at: 0) } else { @@ -51,16 +208,67 @@ struct PreferencesView: View { } } } - return (primarySpaces, availableSpaces, systemDefaultSpace) } // swiftlint:enable large_tuple opening_brace var body: some View { - let (primarySpaces, availableSpaces, systemDefaultSpace) = - getColorSpaces() + let (primarySpaces, availableSpaces, systemDefaultSpace) = getColorSpaces() + VStack(alignment: .leading, spacing: 8.0) { + Section(header: Text(PikaText.textFormatTitle).font(.system(size: 16))) { + VStack(alignment: .leading, spacing: 12.0) { + Section(header: Text(PikaText.textFormatDescription).font(.system(size: 13, weight: .medium))) { + Picker( + PikaText.textSpaceTitle, + selection: $colorSpace.onChange(perform: { + NSColorPanel.shared.close() + Defaults[.colorSpace] = $0 + }) + ) { + ForEach(primarySpaces, id: \.self) { value in + if value == systemDefaultSpace { + Text("\(PikaText.textSystemDefault) (\(value.localizedName!))").tag(value) + } else { + Text(value.localizedName!).tag(value) + } + } + Divider() + ForEach(availableSpaces, id: \.self) { value in + Text(value.localizedName!).tag(value) + } + } + .labelsHidden() + } + } + } + .padding(.horizontal, 24.0) + } + } +} + +private struct GlobalShortcutSection: View { + var body: some View { + VStack(alignment: .leading, spacing: 8.0) { + Section(header: Text(PikaText.textHotkeyTitle).font(.system(size: 16))) { + VStack(alignment: .leading, spacing: 12.0) { + Text(PikaText.textHotkeyDescription).font(.system(size: 13, weight: .medium)) + KeyboardShortcuts.Recorder(for: .togglePika) + } + } + .padding(.horizontal, 24.0) + } + .padding(.bottom, 24.0) + } +} + +// MARK: - Main View + +struct PreferencesView: View { + @State private var viewHeight: CGFloat = 0.0 + + var body: some View { HStack(alignment: .top, spacing: 0) { ZStack { Visualisation() @@ -76,118 +284,16 @@ struct PreferencesView: View { Divider() VStack(alignment: .leading, spacing: 0) { - // General Settings - VStack(alignment: .leading, spacing: 0) { if #available(macOS 26, *) { Divider() } - HStack(alignment: .top, spacing: 0) { - VStack(alignment: .leading, spacing: 10.0) { - Text(PikaText.textGeneralTitle).font( - .system(size: 16)) - LaunchAtLogin.Toggle { - Text(PikaText.textLaunchDescription) - } - #if TARGET_SPARKLE - Toggle(isOn: $betaUpdates) { - Text(PikaText.textBetaDescription) - } - #endif - Toggle(isOn: $alwaysShowOnLaunch) { - Text(PikaText.textAlwaysShowOnLaunch) - } - #if TARGET_SPARKLE - .onReceive([betaUpdates].publisher.first()) { _ in - NSApp.sendAction( - #selector(AppDelegate.updateFeedURL), - to: nil, from: nil - ) - } - #endif - if appMode != .menubar { - Toggle(isOn: $disableHideMenuBarIcon) { - Text(PikaText.textIconDescription) - }.disabled(true) - } else { - Toggle(isOn: $hideMenuBarIcon) { - Text(PikaText.textIconDescription) - } - } - } - .frame( - minWidth: 0, maxWidth: .infinity, - alignment: .leading - ) - .padding(.all, 24.0) - - Divider() - - VStack(alignment: .leading, spacing: 10.0) { - Text(PikaText.textSelectionTitle).font( - .system(size: 16)) - Toggle(isOn: $hidePikaWhilePicking) { - Text(PikaText.textPickHide) - } - Toggle(isOn: $hideColorNames) { - Text(PikaText.textColorNamesDescription) - } - Toggle(isOn: $appFloating) { - Text(PikaText.textFloatDescription) - .frame( - maxWidth: .infinity, alignment: .leading - ) - } - - Toggle(isOn: $showColorOverlay) { - Text(PikaText.textShowColorOverlay) - } - - if showColorOverlay { - HStack(spacing: 8.0) { - Text(PikaText.textDuration) - .font(.system(size: 12)) - Slider( - value: $colorOverlayDuration, - in: 1.0 ... 5.0, - step: 0.5 - ) - Text(String(format: "%.1fs", colorOverlayDuration)) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - .padding(.leading, 20.0) - } - } - .frame( - minWidth: 0, maxWidth: .infinity, - alignment: .leading - ) - .padding(.all, 24.0) - } - .fixedSize(horizontal: false, vertical: true) - - Divider() - .padding(.bottom, 16.0) - - VStack(alignment: .leading, spacing: 10.0) { - Text(PikaText.textAppTitle).font(.system(size: 16)) + GeneralAndSelectionSection() - GeometryReader { geometry in - let width = geometry.size.width - let horizontalUnit = width / 2 + Divider().padding(.bottom, 16.0) - HStack(spacing: 16.0) { - AppModeButtons( - width: horizontalUnit - 8 - ) - } - .frame(maxWidth: width) - } - .frame(height: 96) - } - .padding(.horizontal, 24.0) + AppModeSection() } .modify { if #available(macOS 26.0, *) { @@ -197,160 +303,21 @@ struct PreferencesView: View { } } - Divider() - .padding(.vertical, 16.0) + Divider().padding(.vertical, 16.0) - // Appearance + AppearanceSection() - VStack(alignment: .leading, spacing: 10.0) { - Text(PikaText.textAppearanceTitle).font(.system(size: 16)) + Divider().padding(.vertical, 16.0) - HStack(spacing: 16.0) { - Picker( - PikaText.textContrastStandard, - selection: $contrastStandard - ) { - ForEach(ContrastStandard.allCases, id: \.self) { value in - Text(value.rawValue) - } - } - .pickerStyle(RadioGroupPickerStyle()) - .horizontalRadioGroupLayout() - .fixedSize() - } - .padding(.bottom, 8.0) - if contrastStandard == .wcag { - GeometryReader { geometry in - let width = geometry.size.width - let horizontalUnit = width / 2 - - HStack(spacing: 16.0) { - CompliancePreviewWCAG( - width: horizontalUnit - 8, - foreground: eyedroppers.foreground, - background: eyedroppers.background - ) - } - .frame(maxWidth: width) - } - .frame(height: 100) - } else { - GeometryReader { geometry in - let width = geometry.size.width - - CompliancePreviewAPCA( - foreground: eyedroppers.foreground, - background: eyedroppers.background - ) - .frame(maxWidth: width) - } - .frame(height: 100) - } - } - .padding(.horizontal, 24.0) - - Divider() - .padding(.vertical, 16.0) - - // Copy Settings - VStack(alignment: .leading, spacing: 10.0) { - Text(PikaText.textCopyTitle).font(.system(size: 16)) - - VStack(alignment: .leading, spacing: 12.0) { - HStack(alignment: .firstTextBaseline, spacing: 8.0) { - Text(PikaText.textCopyExport) - .fixedSize() - Picker( - PikaText.textCopyFormat, selection: $copyFormat - ) { - ForEach(CopyFormat.allCases, id: \.self) { value in - Text(value.localizedString()) - } - } - .pickerStyle(.menu) - .labelsHidden() - } + CopySettingsSection() - ColorExampleRow( - copyFormat: copyFormat, - eyedropper: eyedroppers.foreground - ) - } + Divider().padding(.vertical, 16.0) - Toggle(isOn: $copyColorOnPick) { - Text(PikaText.textCopyAutomatic) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(.horizontal, 24.0) - - Divider() - .padding(.vertical, 16.0) - - // Color Format - - VStack(alignment: .leading, spacing: 8.0) { - Section( - header: Text(PikaText.textFormatTitle).font( - .system(size: 16)) - ) { - VStack(alignment: .leading, spacing: 12.0) { - Section( - header: - Text(PikaText.textFormatDescription).font( - .system(size: 13, weight: .medium)) - ) { - Picker( - PikaText.textSpaceTitle, - selection: - $colorSpace.onChange(perform: { - NSColorPanel.shared.close() - Defaults[.colorSpace] = $0 - }) - ) { - ForEach(primarySpaces, id: \.self) { value in - if value == systemDefaultSpace { - Text( - "\(PikaText.textSystemDefault) (\(value.localizedName!))" - ) - .tag(value) - } else { - Text(value.localizedName!) - .tag(value) - } - } - Divider() - ForEach(availableSpaces, id: \.self) { value in - Text(value.localizedName!) - .tag(value) - } - } - .labelsHidden() - } - } - } - .padding(.horizontal, 24.0) - } + ColorFormatSection() - Divider() - .padding(.vertical, 16.0) + Divider().padding(.vertical, 16.0) - // Global Shortcut - - VStack(alignment: .leading, spacing: 8.0) { - Section( - header: Text(PikaText.textHotkeyTitle).font( - .system(size: 16)) - ) { - VStack(alignment: .leading, spacing: 12.0) { - Text(PikaText.textHotkeyDescription).font( - .system(size: 13, weight: .medium)) - KeyboardShortcuts.Recorder(for: .togglePika) - } - } - .padding(.horizontal, 24.0) - } - .padding(.bottom, 24.0) + GlobalShortcutSection() } .background( GeometryReader { contentGeometry in diff --git a/Pika/Views/Toast.swift b/Pika/Views/Toast.swift index 3adf7fe9..fc76bd2e 100644 --- a/Pika/Views/Toast.swift +++ b/Pika/Views/Toast.swift @@ -17,15 +17,7 @@ struct Toast: View where Presenting: View { let color: Color var body: some View { - if isShowing { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - withAnimation { - isShowing = false - } - } - } - return GeometryReader { _ in - + GeometryReader { _ in ZStack(alignment: .topLeading) { presenting() @@ -47,5 +39,12 @@ struct Toast: View where Presenting: View { .offset(x: 8.0, y: 8.0) } } + .task(id: isShowing) { + guard isShowing else { return } + try? await Task.sleep(for: .seconds(1)) + withAnimation { + isShowing = false + } + } } } From cbfd845f86c94a73c1ba21c96e8f148333c7c482 Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 13:43:44 -0700 Subject: [PATCH 02/18] Refactor: dissolve Cula.swift into focused NSColor extension files Split remaining Cula.swift content into four extension files: - NSColor+Init.swift: convenience initialisers (r/g/b and hex string) - NSColor+Luminance.swift: luminance, contrastRatio, toContrastRatioString - NSColor+Hex.swift: roundToHex, toHex, toHexString - NSColor+RGB.swift: toRGBAComponents, toRGBString, toRGB8BitArray, toFormat, getUIColor Deletes Cula.swift entirely. Register all new files in both Xcode targets. Co-Authored-By: Claude Sonnet 4.6 --- Pika.xcodeproj/project.pbxproj | 30 +++- Pika/Extensions/Cula.swift | 196 ------------------------ Pika/Extensions/NSColor+Hex.swift | 18 +++ Pika/Extensions/NSColor+Init.swift | 42 +++++ Pika/Extensions/NSColor+Luminance.swift | 33 ++++ Pika/Extensions/NSColor+RGB.swift | 74 +++++++++ 6 files changed, 191 insertions(+), 202 deletions(-) delete mode 100644 Pika/Extensions/Cula.swift create mode 100644 Pika/Extensions/NSColor+Hex.swift create mode 100644 Pika/Extensions/NSColor+Init.swift create mode 100644 Pika/Extensions/NSColor+Luminance.swift create mode 100644 Pika/Extensions/NSColor+RGB.swift diff --git a/Pika.xcodeproj/project.pbxproj b/Pika.xcodeproj/project.pbxproj index 467016bc..a3050c3a 100644 --- a/Pika.xcodeproj/project.pbxproj +++ b/Pika.xcodeproj/project.pbxproj @@ -24,6 +24,14 @@ CC000001000000000000AAB2 /* NSColor+HSL.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAB0 /* NSColor+HSL.swift */; }; CC000001000000000000AAC1 /* NSColor+Lab.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAC0 /* NSColor+Lab.swift */; }; CC000001000000000000AAC2 /* NSColor+Lab.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAC0 /* NSColor+Lab.swift */; }; + CC000001000000000000AAD1 /* NSColor+Init.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAD0 /* NSColor+Init.swift */; }; + CC000001000000000000AAD2 /* NSColor+Init.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAD0 /* NSColor+Init.swift */; }; + CC000001000000000000AAE1 /* NSColor+Luminance.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAE0 /* NSColor+Luminance.swift */; }; + CC000001000000000000AAE2 /* NSColor+Luminance.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAE0 /* NSColor+Luminance.swift */; }; + CC000001000000000000AAF1 /* NSColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAF0 /* NSColor+Hex.swift */; }; + CC000001000000000000AAF2 /* NSColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AAF0 /* NSColor+Hex.swift */; }; + CC000001000000000000AB01 /* NSColor+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AB00 /* NSColor+RGB.swift */; }; + CC000001000000000000AB02 /* NSColor+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AB00 /* NSColor+RGB.swift */; }; EA0C525025AA729300AFF716 /* Visualisation.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C524F25AA729300AFF716 /* Visualisation.swift */; }; EA0C526025AB5A2B00AFF716 /* NavigationMenuItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C525F25AB5A2B00AFF716 /* NavigationMenuItems.swift */; }; EA0C526425AB5D1700AFF716 /* PikaWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C526325AB5D1700AFF716 /* PikaWindow.swift */; }; @@ -32,7 +40,6 @@ EA257BD125D8629300C3FC54 /* SwapButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA257BD025D8629300C3FC54 /* SwapButtonStyle.swift */; }; EA424C7D25CDEF98009056A9 /* ComplianceToggleGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA424C7C25CDEF98009056A9 /* ComplianceToggleGroup.swift */; }; EA424CDE25CF0328009056A9 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = EA424CE025CF0328009056A9 /* Localizable.strings */; }; - EA635DC925B3B2650014D91A /* Cula.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA635DC825B3B2650014D91A /* Cula.swift */; }; EA635DCC25B3B42B0014D91A /* WCAGCompliance.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA635DCB25B3B42B0014D91A /* WCAGCompliance.swift */; }; EA635DDC25B4E90B0014D91A /* BindingOnChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA635DDB25B4E90B0014D91A /* BindingOnChange.swift */; }; EA635DE125B4FC580014D91A /* ColorPickers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA635DE025B4FC580014D91A /* ColorPickers.swift */; }; @@ -104,7 +111,6 @@ EAE23DC92D032A38005BB270 /* SwapButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA257BD025D8629300C3FC54 /* SwapButtonStyle.swift */; }; EAE23DCA2D032A38005BB270 /* NSWindowFade.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA72BBA825A7CE9C008205E7 /* NSWindowFade.swift */; }; EAE23DCB2D032A38005BB270 /* EyedropperButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD0B717259D146200FA2F67 /* EyedropperButtonStyle.swift */; }; - EAE23DCC2D032A38005BB270 /* Cula.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA635DC825B3B2650014D91A /* Cula.swift */; }; EAE23DCD2D032A38005BB270 /* CircleButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAEBF64625E878A5002999D1 /* CircleButtonStyle.swift */; }; EAE23DCE2D032A38005BB270 /* ColorPickers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA635DE025B4FC580014D91A /* ColorPickers.swift */; }; EAE23DCF2D032A38005BB270 /* Exporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACA8A44260501210064035C /* Exporter.swift */; }; @@ -161,6 +167,10 @@ CC000001000000000000AAA0 /* Color+PikaControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+PikaControl.swift"; sourceTree = ""; }; CC000001000000000000AAB0 /* NSColor+HSL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+HSL.swift"; sourceTree = ""; }; CC000001000000000000AAC0 /* NSColor+Lab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+Lab.swift"; sourceTree = ""; }; + CC000001000000000000AAD0 /* NSColor+Init.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+Init.swift"; sourceTree = ""; }; + CC000001000000000000AAE0 /* NSColor+Luminance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+Luminance.swift"; sourceTree = ""; }; + CC000001000000000000AAF0 /* NSColor+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+Hex.swift"; sourceTree = ""; }; + CC000001000000000000AB00 /* NSColor+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+RGB.swift"; sourceTree = ""; }; EA0C524F25AA729300AFF716 /* Visualisation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Visualisation.swift; sourceTree = ""; }; EA0C525F25AB5A2B00AFF716 /* NavigationMenuItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationMenuItems.swift; sourceTree = ""; }; EA0C526325AB5D1700AFF716 /* PikaWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PikaWindow.swift; sourceTree = ""; }; @@ -171,7 +181,6 @@ EA16C6562CF3301500FBC93E /* .swiftlint.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = ""; }; EA257BD025D8629300C3FC54 /* SwapButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapButtonStyle.swift; sourceTree = ""; }; EA424C7C25CDEF98009056A9 /* ComplianceToggleGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComplianceToggleGroup.swift; sourceTree = ""; }; - EA635DC825B3B2650014D91A /* Cula.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cula.swift; sourceTree = ""; }; EA635DCB25B3B42B0014D91A /* WCAGCompliance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WCAGCompliance.swift; sourceTree = ""; }; EA635DDB25B4E90B0014D91A /* BindingOnChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BindingOnChange.swift; sourceTree = ""; }; EA635DE025B4FC580014D91A /* ColorPickers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickers.swift; sourceTree = ""; }; @@ -339,7 +348,6 @@ EAE2EBA12D03C3FE00FA9BC9 /* LatestAppStoreVersion+ShouldUpdate.swift */, EAE2EB9E2D03C3DA00FA9BC9 /* LookUpAPI.swift */, EA635DDB25B4E90B0014D91A /* BindingOnChange.swift */, - EA635DC825B3B2650014D91A /* Cula.swift */, 221600FC25A636D600B8B7D9 /* ConditionalModifier.swift */, EA72BBA825A7CE9C008205E7 /* NSWindowFade.swift */, EA635DCB25B3B42B0014D91A /* WCAGCompliance.swift */, @@ -348,6 +356,10 @@ CC000001000000000000AAA0 /* Color+PikaControl.swift */, CC000001000000000000AAB0 /* NSColor+HSL.swift */, CC000001000000000000AAC0 /* NSColor+Lab.swift */, + CC000001000000000000AAD0 /* NSColor+Init.swift */, + CC000001000000000000AAE0 /* NSColor+Luminance.swift */, + CC000001000000000000AAF0 /* NSColor+Hex.swift */, + CC000001000000000000AB00 /* NSColor+RGB.swift */, ); path = Extensions; sourceTree = ""; @@ -653,13 +665,16 @@ CC000001000000000000AAA1 /* Color+PikaControl.swift in Sources */, CC000001000000000000AAB1 /* NSColor+HSL.swift in Sources */, CC000001000000000000AAC1 /* NSColor+Lab.swift in Sources */, + CC000001000000000000AAD1 /* NSColor+Init.swift in Sources */, + CC000001000000000000AAE1 /* NSColor+Luminance.swift in Sources */, + CC000001000000000000AAF1 /* NSColor+Hex.swift in Sources */, + CC000001000000000000AB01 /* NSColor+RGB.swift in Sources */, EAA8AE1925B8EC070049299B /* KeyboardShortcutKey.swift in Sources */, EA0C526F25AB683400AFF716 /* EyedropperButton.swift in Sources */, EA72BB8425A5334B008205E7 /* MetalShader.metal in Sources */, EA257BD125D8629300C3FC54 /* SwapButtonStyle.swift in Sources */, EA72BBA925A7CE9C008205E7 /* NSWindowFade.swift in Sources */, EAD0B718259D146200FA2F67 /* EyedropperButtonStyle.swift in Sources */, - EA635DC925B3B2650014D91A /* Cula.swift in Sources */, EAEBF64725E878A5002999D1 /* CircleButtonStyle.swift in Sources */, F8ABAC5D2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift in Sources */, EA635DE125B4FC580014D91A /* ColorPickers.swift in Sources */, @@ -690,6 +705,10 @@ CC000001000000000000AAA2 /* Color+PikaControl.swift in Sources */, CC000001000000000000AAB2 /* NSColor+HSL.swift in Sources */, CC000001000000000000AAC2 /* NSColor+Lab.swift in Sources */, + CC000001000000000000AAD2 /* NSColor+Init.swift in Sources */, + CC000001000000000000AAE2 /* NSColor+Luminance.swift in Sources */, + CC000001000000000000AAF2 /* NSColor+Hex.swift in Sources */, + CC000001000000000000AB02 /* NSColor+RGB.swift in Sources */, EAE23DAE2D032A38005BB270 /* SplashTouchBar.swift in Sources */, EAE23DAF2D032A38005BB270 /* OverflowContentViewModifier.swift in Sources */, EAE23DB02D032A38005BB270 /* KeyboardShortcutGrid.swift in Sources */, @@ -723,7 +742,6 @@ EAE23DC92D032A38005BB270 /* SwapButtonStyle.swift in Sources */, EAE23DCA2D032A38005BB270 /* NSWindowFade.swift in Sources */, EAE23DCB2D032A38005BB270 /* EyedropperButtonStyle.swift in Sources */, - EAE23DCC2D032A38005BB270 /* Cula.swift in Sources */, EAE23DCD2D032A38005BB270 /* CircleButtonStyle.swift in Sources */, EAE23DCE2D032A38005BB270 /* ColorPickers.swift in Sources */, EAE23DCF2D032A38005BB270 /* Exporter.swift in Sources */, diff --git a/Pika/Extensions/Cula.swift b/Pika/Extensions/Cula.swift deleted file mode 100644 index 03f9ed30..00000000 --- a/Pika/Extensions/Cula.swift +++ /dev/null @@ -1,196 +0,0 @@ -import Cocoa -import Defaults -import SwiftUI - -// swiftlint:disable identifier_name - -extension NSColor { - /* - * Initialisers - */ - - convenience init(r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat = 1) { - if (r > 1) || (g > 1) || (b > 1) { - self.init(red: r / 255, green: g / 255, blue: b / 255, alpha: a) - } else { - self.init(red: r, green: g, blue: b, alpha: a) - } - } - - /** - Create a UIColor with a string hex value. - - - parameter hex: The hex color, i.e. "FF0072" or "#FF0072". - - parameter alpha: The opacity of the color, value between [0,1]. Optional. Default: 1 - */ - convenience init(hex: String, alpha: CGFloat = 1) { - var hex = hex.replacingOccurrences(of: "#", with: "") - - guard hex.count == 3 || hex.count == 6 else { - fatalError("Hex characters must be either 3 or 6 characters.") - } - - if hex.count == 3 { - let tmp = hex - hex = "" - for c in tmp { - hex += String([c, c]) - } - } - - let scanner = Scanner(string: hex) - var rgb: UInt64 = 0 - scanner.scanHexInt64(&rgb) - - let R = CGFloat((rgb >> 16) & 0xFF) / 255 - let G = CGFloat((rgb >> 8) & 0xFF) / 255 - let B = CGFloat(rgb & 0xFF) / 255 - self.init(red: R, green: G, blue: B, alpha: alpha) - } - - /* - * Utilities - */ - - func clip(_ v: T, _ minimum: T, _ maximum: T) -> T { - max(min(v, maximum), minimum) - } - - func contrastRatio(with color: NSColor) -> CGFloat { - let L1 = luminance - let L2 = color.luminance - - if L1 < L2 { - return (L2 + 0.05) / (L1 + 0.05) - } else { - return (L1 + 0.05) / (L2 + 0.05) - } - } - - func toContrastRatioString(with color: NSColor) -> String { - Double(round(100 * contrastRatio(with: color)) / 100).description as String - } - - var luminance: CGFloat { - let rgba = toRGBAComponents(in: .extendedSRGB) - - func lumHelper(c: CGFloat) -> CGFloat { - (c < 0.03928) ? (c / 12.92) : pow((c + 0.055) / 1.055, 2.4) - } - - let result = 0.2126 * lumHelper(c: rgba.r) + 0.7152 * lumHelper(c: rgba.g) + 0.0722 * lumHelper(c: rgba.b) - return max(.zero, result) - } - - /* - * Hex - */ - - func roundToHex(_ x: CGFloat) -> UInt32 { - guard x > 0 else { return 0 } - let rounded: CGFloat = round(x * 255.0) - return UInt32(rounded) - } - - func toHex() -> UInt32 { - let rgba = toRGBAComponents() - return roundToHex(rgba.r) << 16 | roundToHex(rgba.g) << 8 | roundToHex(rgba.b) - } - - func toHexString(style: CopyFormat = .css) -> String { - String(format: style == .css ? "#%06x" : "%06x", toHex()) - } - - /* - * RGBA - */ - - final func toRGBAComponents(in colorSpace: NSColorSpace = Defaults[.colorSpace]) - -> (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) - { - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - - guard let rgbaColor = usingColorSpace(colorSpace) else { - fatalError("Could not convert color to RGBA") - } - - rgbaColor.getRed(&r, green: &g, blue: &b, alpha: &a) - - return (r, g, b, a) - } - - /** - Get the rgb values of this color in 8-bit format. - - - returns: An NSColor as an 8-bit rgb string. - */ - func toRGBString(style: CopyFormat = .css) -> String { - let RGB = toRGBAComponents() - let red = Int(round(RGB.r * 255)) - let green = Int(round(RGB.g * 255)) - let blue = Int(round(RGB.b * 255)) - - let rgbString: String - switch style { - case .css, .design: - rgbString = String(format: "rgb(%d, %d, %d)", red, green, blue) - case .swiftUI: - rgbString = String(format: "Color(red: %.5g, green: %.5g, blue: %.5g)", RGB.r, RGB.g, RGB.b) - case .unformatted: - rgbString = String(format: "%d, %d, %d", red, green, blue) - } - - return rgbString - } - - /** - Get the rgb values of this color in 8-bit format. - - - returns: An NSColor as an 8-bit rgb array. - */ - func toRGB8BitArray() -> [Int] { - let RGB = toRGBAComponents() - let red = Int(round(RGB.r * 255)) - let green = Int(round(RGB.g * 255)) - let blue = Int(round(RGB.b * 255)) - return [red, green, blue] - } - - /* - * Helpers - */ - - /** - Returns hex and string formats of each color - - - returns: A string of the color depending on the provided format. - */ - func toFormat(format: ColorFormat, style: CopyFormat = .css) -> String { - switch format { - case .hex: - return toHexString(style: style) - case .rgb: - return toRGBString(style: style) - case .hsb: - return toHSBString(style: style) - case .hsl: - return toHSLString(style: style) - case .opengl: - return toOpenGLString(style: style) - case .lab: - return toLabString(style: style) - case .oklch: - return toOklchString(style: style) - } - } - - func getUIColor() -> (Color) { - luminance < 0.5 ? Color.white : Color.black - } - - func getUIColor() -> (NSColor) { - luminance < 0.5 ? NSColor.white : NSColor.black - } -} - -// swiftlint:enable identifier_name diff --git a/Pika/Extensions/NSColor+Hex.swift b/Pika/Extensions/NSColor+Hex.swift new file mode 100644 index 00000000..1fc35abd --- /dev/null +++ b/Pika/Extensions/NSColor+Hex.swift @@ -0,0 +1,18 @@ +import Cocoa + +extension NSColor { + func roundToHex(_ x: CGFloat) -> UInt32 { + guard x > 0 else { return 0 } + let rounded: CGFloat = round(x * 255.0) + return UInt32(rounded) + } + + func toHex() -> UInt32 { + let rgba = toRGBAComponents() + return roundToHex(rgba.r) << 16 | roundToHex(rgba.g) << 8 | roundToHex(rgba.b) + } + + func toHexString(style: CopyFormat = .css) -> String { + String(format: style == .css ? "#%06x" : "%06x", toHex()) + } +} diff --git a/Pika/Extensions/NSColor+Init.swift b/Pika/Extensions/NSColor+Init.swift new file mode 100644 index 00000000..52964d19 --- /dev/null +++ b/Pika/Extensions/NSColor+Init.swift @@ -0,0 +1,42 @@ +import Cocoa + +extension NSColor { + convenience init(r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat = 1) { + if (r > 1) || (g > 1) || (b > 1) { + self.init(red: r / 255, green: g / 255, blue: b / 255, alpha: a) + } else { + self.init(red: r, green: g, blue: b, alpha: a) + } + } + + /** + Create an NSColor with a string hex value. + + - parameter hex: The hex color, i.e. "FF0072" or "#FF0072". + - parameter alpha: The opacity of the color, value between [0,1]. Optional. Default: 1 + */ + convenience init(hex: String, alpha: CGFloat = 1) { + var hex = hex.replacingOccurrences(of: "#", with: "") + + guard hex.count == 3 || hex.count == 6 else { + fatalError("Hex characters must be either 3 or 6 characters.") + } + + if hex.count == 3 { + let tmp = hex + hex = "" + for c in tmp { + hex += String([c, c]) + } + } + + let scanner = Scanner(string: hex) + var rgb: UInt64 = 0 + scanner.scanHexInt64(&rgb) + + let R = CGFloat((rgb >> 16) & 0xFF) / 255 + let G = CGFloat((rgb >> 8) & 0xFF) / 255 + let B = CGFloat(rgb & 0xFF) / 255 + self.init(red: R, green: G, blue: B, alpha: alpha) + } +} diff --git a/Pika/Extensions/NSColor+Luminance.swift b/Pika/Extensions/NSColor+Luminance.swift new file mode 100644 index 00000000..548df5d6 --- /dev/null +++ b/Pika/Extensions/NSColor+Luminance.swift @@ -0,0 +1,33 @@ +import Cocoa + +extension NSColor { + func clip(_ v: T, _ minimum: T, _ maximum: T) -> T { + max(min(v, maximum), minimum) + } + + var luminance: CGFloat { + let rgba = toRGBAComponents(in: .extendedSRGB) + + func lumHelper(c: CGFloat) -> CGFloat { + (c < 0.03928) ? (c / 12.92) : pow((c + 0.055) / 1.055, 2.4) + } + + let result = 0.2126 * lumHelper(c: rgba.r) + 0.7152 * lumHelper(c: rgba.g) + 0.0722 * lumHelper(c: rgba.b) + return max(.zero, result) + } + + func contrastRatio(with color: NSColor) -> CGFloat { + let L1 = luminance + let L2 = color.luminance + + if L1 < L2 { + return (L2 + 0.05) / (L1 + 0.05) + } else { + return (L1 + 0.05) / (L2 + 0.05) + } + } + + func toContrastRatioString(with color: NSColor) -> String { + Double(round(100 * contrastRatio(with: color)) / 100).description as String + } +} diff --git a/Pika/Extensions/NSColor+RGB.swift b/Pika/Extensions/NSColor+RGB.swift new file mode 100644 index 00000000..a0ae5c2b --- /dev/null +++ b/Pika/Extensions/NSColor+RGB.swift @@ -0,0 +1,74 @@ +import Cocoa +import Defaults +import SwiftUI + +// swiftlint:disable identifier_name + +extension NSColor { + final func toRGBAComponents(in colorSpace: NSColorSpace = Defaults[.colorSpace]) + -> (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) + { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + + guard let rgbaColor = usingColorSpace(colorSpace) else { + fatalError("Could not convert color to RGBA") + } + + rgbaColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + return (r, g, b, a) + } + + func toRGBString(style: CopyFormat = .css) -> String { + let RGB = toRGBAComponents() + let red = Int(round(RGB.r * 255)) + let green = Int(round(RGB.g * 255)) + let blue = Int(round(RGB.b * 255)) + + switch style { + case .css, .design: + return String(format: "rgb(%d, %d, %d)", red, green, blue) + case .swiftUI: + return String(format: "Color(red: %.5g, green: %.5g, blue: %.5g)", RGB.r, RGB.g, RGB.b) + case .unformatted: + return String(format: "%d, %d, %d", red, green, blue) + } + } + + func toRGB8BitArray() -> [Int] { + let RGB = toRGBAComponents() + let red = Int(round(RGB.r * 255)) + let green = Int(round(RGB.g * 255)) + let blue = Int(round(RGB.b * 255)) + return [red, green, blue] + } + + func toFormat(format: ColorFormat, style: CopyFormat = .css) -> String { + switch format { + case .hex: + return toHexString(style: style) + case .rgb: + return toRGBString(style: style) + case .hsb: + return toHSBString(style: style) + case .hsl: + return toHSLString(style: style) + case .opengl: + return toOpenGLString(style: style) + case .lab: + return toLabString(style: style) + case .oklch: + return toOklchString(style: style) + } + } + + func getUIColor() -> Color { + luminance < 0.5 ? Color.white : Color.black + } + + func getUIColor() -> NSColor { + luminance < 0.5 ? NSColor.white : NSColor.black + } +} + +// swiftlint:enable identifier_name From 4da9e2c47a21471364f08d54a7f94fb6533f5ea2 Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 13:48:55 -0700 Subject: [PATCH 03/18] Fix: cancel existing hoverTask before creating new one in SwapButtonStyle Consistent with the pattern used in EyedropperButton and ContentView. Co-Authored-By: Claude Sonnet 4.6 --- Pika/Styles/SwapButtonStyle.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Pika/Styles/SwapButtonStyle.swift b/Pika/Styles/SwapButtonStyle.swift index 6b5a110c..0b74b2c9 100644 --- a/Pika/Styles/SwapButtonStyle.swift +++ b/Pika/Styles/SwapButtonStyle.swift @@ -58,6 +58,7 @@ struct SwapButtonStyle: ButtonStyle { ) .onHover { hover in if hover { + hoverTask?.cancel() hoverTask = Task { try? await Task.sleep(for: .milliseconds(100)) isHovered = true From c64be527d95333bb441331cb12c19b0cb8b2428d Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 15:45:38 -0700 Subject: [PATCH 04/18] Add PikaTests unit test suite and CI workflow - PikaTests XCTest target hosted against Pika.app (both targets in scheme) - 9 test files covering NSColor extensions, WCAG/APCA compliance, and Notification.Name constants (75+ test cases total): - NSColorInitTests: init(r:g:b:a:) normalisation, init(hex:) parsing - NSColorLuminanceTests: clip, luminance, contrastRatio - NSColorHexTests: roundToHex, toHex, toHexString round-trip - NSColorRGBTests: toRGBAComponents, toRGBString, toRGB8BitArray, toFormat, getUIColor - NSColorHSLTests: toHSBComponents/String, toHSLComponents/String - NSColorLabTests: toOpenGLString, toLabComponents/String, toOklchComponents/String - WCAGComplianceTests: 3:1/4.5:1/7:1 boundary cases, symmetry, cumulativity - APCAComplianceTests: all level strings, value formatting, symmetry - NotificationNamesTests: all 19 typed Notification.Name constants vs raw strings - .github/workflows/tests.yml: runs on every PR and push to main (macOS-15, Xcode 16.3) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/tests.yml | 41 +++++ Pika.xcodeproj/project.pbxproj | 140 ++++++++++++++++++ .../xcshareddata/xcschemes/Pika.xcscheme | 25 ++++ PikaTests/APCAComplianceTests.swift | 79 ++++++++++ PikaTests/NSColorHSLTests.swift | 113 ++++++++++++++ PikaTests/NSColorHexTests.swift | 93 ++++++++++++ PikaTests/NSColorInitTests.swift | 98 ++++++++++++ PikaTests/NSColorLabTests.swift | 114 ++++++++++++++ PikaTests/NSColorLuminanceTests.swift | 114 ++++++++++++++ PikaTests/NSColorRGBTests.swift | 128 ++++++++++++++++ PikaTests/NotificationNamesTests.swift | 103 +++++++++++++ PikaTests/WCAGComplianceTests.swift | 84 +++++++++++ 12 files changed, 1132 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 PikaTests/APCAComplianceTests.swift create mode 100644 PikaTests/NSColorHSLTests.swift create mode 100644 PikaTests/NSColorHexTests.swift create mode 100644 PikaTests/NSColorInitTests.swift create mode 100644 PikaTests/NSColorLabTests.swift create mode 100644 PikaTests/NSColorLuminanceTests.swift create mode 100644 PikaTests/NSColorRGBTests.swift create mode 100644 PikaTests/NotificationNamesTests.swift create mode 100644 PikaTests/WCAGComplianceTests.swift diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..15e82186 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + name: Unit Tests + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_16.3.app + + - name: Resolve Swift packages + run: | + xcodebuild -resolvePackageDependencies \ + -project Pika.xcodeproj \ + -scheme Pika + + - name: Build and test + run: | + xcodebuild test \ + -project Pika.xcodeproj \ + -scheme Pika \ + -destination 'platform=macOS' \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty --color && exit ${PIPESTATUS[0]} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: ~/Library/Developer/Xcode/DerivedData/**/Logs/Test/*.xcresult + retention-days: 7 diff --git a/Pika.xcodeproj/project.pbxproj b/Pika.xcodeproj/project.pbxproj index a3050c3a..52f44778 100644 --- a/Pika.xcodeproj/project.pbxproj +++ b/Pika.xcodeproj/project.pbxproj @@ -7,6 +7,17 @@ objects = { /* Begin PBXBuildFile section */ + CC20000000000000000000F2 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC20000000000000000000D2 /* XCTest.framework */; }; + CC2000000000000000000101 /* NSColorInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000100 /* NSColorInitTests.swift */; }; + CC2000000000000000000201 /* NSColorLuminanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000200 /* NSColorLuminanceTests.swift */; }; + CC2000000000000000000301 /* NSColorHexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000300 /* NSColorHexTests.swift */; }; + CC2000000000000000000401 /* NSColorRGBTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000400 /* NSColorRGBTests.swift */; }; + CC2000000000000000000501 /* NSColorHSLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000500 /* NSColorHSLTests.swift */; }; + CC2000000000000000000601 /* NSColorLabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000600 /* NSColorLabTests.swift */; }; + CC2000000000000000000701 /* WCAGComplianceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000700 /* WCAGComplianceTests.swift */; }; + CC2000000000000000000801 /* APCAComplianceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000800 /* APCAComplianceTests.swift */; }; + CC2000000000000000000901 /* NotificationNamesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000900 /* NotificationNamesTests.swift */; }; + 220D5E9428DB154300B6285E /* AppModeButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220D5E9328DB154300B6285E /* AppModeButtons.swift */; }; 220D5E9828DB158400B6285E /* AppModeToggleGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220D5E9728DB158400B6285E /* AppModeToggleGroup.swift */; }; 221600F925A62E5B00B8B7D9 /* IconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221600F825A62E5B00B8B7D9 /* IconImage.swift */; }; @@ -142,7 +153,37 @@ F8ABAC5E2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8ABAC5C2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + CC20000000000000000000A2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EAD0B6C0259CED1C00FA2F67 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EAD0B6C7259CED1C00FA2F67; + remoteInfo = Pika; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXTargetDependency section */ + CC20000000000000000000B2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EAD0B6C7259CED1C00FA2F67 /* Pika */; + targetProxy = CC20000000000000000000A2 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXFileReference section */ + CC20000000000000000000D2 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_FRAMEWORKS_DIR; }; + CC20000000000000000000E2 /* PikaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PikaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + CC2000000000000000000100 /* NSColorInitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSColorInitTests.swift; sourceTree = ""; }; + CC2000000000000000000200 /* NSColorLuminanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSColorLuminanceTests.swift; sourceTree = ""; }; + CC2000000000000000000300 /* NSColorHexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSColorHexTests.swift; sourceTree = ""; }; + CC2000000000000000000400 /* NSColorRGBTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSColorRGBTests.swift; sourceTree = ""; }; + CC2000000000000000000500 /* NSColorHSLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSColorHSLTests.swift; sourceTree = ""; }; + CC2000000000000000000600 /* NSColorLabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSColorLabTests.swift; sourceTree = ""; }; + CC2000000000000000000700 /* WCAGComplianceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WCAGComplianceTests.swift; sourceTree = ""; }; + CC2000000000000000000800 /* APCAComplianceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APCAComplianceTests.swift; sourceTree = ""; }; + CC2000000000000000000900 /* NotificationNamesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationNamesTests.swift; sourceTree = ""; }; + 220D5E9328DB154300B6285E /* AppModeButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppModeButtons.swift; sourceTree = ""; }; 220D5E9728DB158400B6285E /* AppModeToggleGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppModeToggleGroup.swift; sourceTree = ""; }; 221600F825A62E5B00B8B7D9 /* IconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconImage.swift; sourceTree = ""; }; @@ -253,6 +294,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CC20000000000000000000C1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CC20000000000000000000F2 /* XCTest.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -301,6 +350,7 @@ EAD0B705259CF3BA00FA2F67 /* LICENSE.md */, EAD0B704259CF3BA00FA2F67 /* README.md */, EAD0B6CA259CED1C00FA2F67 /* Pika */, + CC20000000000000000000C2 /* PikaTests */, EAD0B6C9259CED1C00FA2F67 /* Products */, ); sourceTree = ""; @@ -310,6 +360,7 @@ children = ( EAD0B6C8259CED1C00FA2F67 /* Pika.app */, EAE23DE82D032A38005BB270 /* Pika.app */, + CC20000000000000000000E2 /* PikaTests.xctest */, ); name = Products; sourceTree = ""; @@ -422,6 +473,22 @@ path = Styles; sourceTree = ""; }; + CC20000000000000000000C2 /* PikaTests */ = { + isa = PBXGroup; + children = ( + CC2000000000000000000100 /* NSColorInitTests.swift */, + CC2000000000000000000200 /* NSColorLuminanceTests.swift */, + CC2000000000000000000300 /* NSColorHexTests.swift */, + CC2000000000000000000400 /* NSColorRGBTests.swift */, + CC2000000000000000000500 /* NSColorHSLTests.swift */, + CC2000000000000000000600 /* NSColorLabTests.swift */, + CC2000000000000000000700 /* WCAGComplianceTests.swift */, + CC2000000000000000000800 /* APCAComplianceTests.swift */, + CC2000000000000000000900 /* NotificationNamesTests.swift */, + ); + path = PikaTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -474,6 +541,23 @@ productReference = EAE23DE82D032A38005BB270 /* Pika.app */; productType = "com.apple.product-type.application"; }; + CC20000000000000000000A1 /* PikaTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CC20000000000000000000D1 /* Build configuration list for PBXNativeTarget "PikaTests" */; + buildPhases = ( + CC20000000000000000000B1 /* Sources */, + CC20000000000000000000C1 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + CC20000000000000000000B2 /* PBXTargetDependency */, + ); + name = PikaTests; + productName = PikaTests; + productReference = CC20000000000000000000E2 /* PikaTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -517,6 +601,7 @@ targets = ( EAD0B6C7259CED1C00FA2F67 /* Pika */, EAE23D9D2D032A38005BB270 /* Pika (Mac App Store) */, + CC20000000000000000000A1 /* PikaTests */, ); }; /* End PBXProject section */ @@ -756,6 +841,22 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CC20000000000000000000B1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CC2000000000000000000101 /* NSColorInitTests.swift in Sources */, + CC2000000000000000000201 /* NSColorLuminanceTests.swift in Sources */, + CC2000000000000000000301 /* NSColorHexTests.swift in Sources */, + CC2000000000000000000401 /* NSColorRGBTests.swift in Sources */, + CC2000000000000000000501 /* NSColorHSLTests.swift in Sources */, + CC2000000000000000000601 /* NSColorLabTests.swift in Sources */, + CC2000000000000000000701 /* WCAGComplianceTests.swift in Sources */, + CC2000000000000000000801 /* APCAComplianceTests.swift in Sources */, + CC2000000000000000000901 /* NotificationNamesTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ @@ -1037,6 +1138,36 @@ }; name = Release; }; + CC20000000000000000000E1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = TGHU37N6EX; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.superhighfives.PikaTests"; + PRODUCT_NAME = PikaTests; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Pika.app/Contents/MacOS/Pika"; + }; + name = Debug; + }; + CC20000000000000000000F1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = TGHU37N6EX; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.superhighfives.PikaTests"; + PRODUCT_NAME = PikaTests; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Pika.app/Contents/MacOS/Pika"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1067,6 +1198,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CC20000000000000000000D1 /* Build configuration list for PBXNativeTarget "PikaTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CC20000000000000000000E1 /* Debug */, + CC20000000000000000000F1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Pika.xcodeproj/xcshareddata/xcschemes/Pika.xcscheme b/Pika.xcodeproj/xcshareddata/xcschemes/Pika.xcscheme index 22231167..ee59cb70 100644 --- a/Pika.xcodeproj/xcshareddata/xcschemes/Pika.xcscheme +++ b/Pika.xcodeproj/xcshareddata/xcschemes/Pika.xcscheme @@ -20,6 +20,20 @@ ReferencedContainer = "container:Pika.xcodeproj"> + + + + + + + + 60) + XCTAssertEqual(result.level, "Super") + } + + func test_whiteOnBlack_isHighLevel() { + let white = NSColor(red: 1, green: 1, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + let black = NSColor(red: 0, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + let result = white.APCACompliance(with: black) + XCTAssertEqual(result.level, "Super") + } + + func test_sameColor_levelIsFail() { + let gray = NSColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1).usingColorSpace(.sRGB)! + let result = gray.APCACompliance(with: gray) + XCTAssertEqual(result.level, "Fail") + } + + func test_level_allCasesAreKnownStrings() { + let knownLevels: Set = ["Fail", "AA", "AAA", "AAA+", "Super"] + let pairs: [(NSColor, NSColor)] = [ + (NSColor(r: 0, g: 0, b: 0), NSColor(r: 0, g: 0, b: 0)), + (NSColor(r: 50, g: 50, b: 50), NSColor(r: 240, g: 240, b: 240)), + (NSColor(r: 0, g: 0, b: 0), NSColor(r: 255, g: 255, b: 255)), + ] + for (fg, bg) in pairs { + let level = fg.APCACompliance(with: bg).level + XCTAssertTrue(knownLevels.contains(level), "Unexpected level: \(level)") + } + } + + // MARK: - toAPCAcontrastValue(with:) + + func test_toAPCAcontrastValue_blackOnWhite_isHighValue() { + let black = NSColor(red: 0, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + let white = NSColor(red: 1, green: 1, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + let value = black.toAPCAcontrastValue(with: white) + let doubleValue = Double(value.replacingOccurrences(of: ",", with: ".")) + XCTAssertNotNil(doubleValue, "Expected numeric string, got '\(value)'") + XCTAssertGreaterThan(doubleValue ?? 0, 60.0) + } + + func test_toAPCAcontrastValue_formattedToTwoDecimalPlaces() { + let black = NSColor(red: 0, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + let white = NSColor(red: 1, green: 1, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + let value = black.toAPCAcontrastValue(with: white) + // Should contain a decimal separator + XCTAssertTrue(value.contains(".") || value.contains(",")) + } + + func test_toAPCAcontrastValue_isAlwaysPositive() { + // The function uses abs(), so result should be non-negative + let colorA = NSColor(r: 100, g: 100, b: 100) + let colorB = NSColor(r: 200, g: 200, b: 200) + let value = colorA.toAPCAcontrastValue(with: colorB) + let doubleValue = Double(value.replacingOccurrences(of: ",", with: ".")) ?? -1 + XCTAssertGreaterThanOrEqual(doubleValue, 0) + } + + // MARK: - toAPCACompliance(with:) alias + + func test_toAPCACompliance_equivalentToAPCACompliance() { + let a = NSColor(r: 50, g: 100, b: 200) + let b = NSColor(r: 220, g: 220, b: 220) + let direct = a.APCACompliance(with: b) + let alias = a.toAPCACompliance(with: b) + XCTAssertEqual(direct.level, alias.level) + XCTAssertEqual(direct.value, alias.value, accuracy: 0.001) + } +} diff --git a/PikaTests/NSColorHSLTests.swift b/PikaTests/NSColorHSLTests.swift new file mode 100644 index 00000000..0d9d80a0 --- /dev/null +++ b/PikaTests/NSColorHSLTests.swift @@ -0,0 +1,113 @@ +import XCTest +@testable import Pika + +final class NSColorHSLTests: XCTestCase { + // MARK: - toHSBComponents() + + func test_toHSBComponents_red_hueIsZero() { + let red = NSColor(hex: "FF0000") + let hsb = red.toHSBComponents() + XCTAssertEqual(hsb.h, 0.0, accuracy: 0.01) + XCTAssertEqual(hsb.s, 1.0, accuracy: 0.01) + XCTAssertEqual(hsb.b, 1.0, accuracy: 0.01) + } + + func test_toHSBComponents_black_allZero() { + let black = NSColor(hex: "000000") + let hsb = black.toHSBComponents() + XCTAssertEqual(hsb.h, 0.0, accuracy: 0.001) + XCTAssertEqual(hsb.s, 0.0, accuracy: 0.001) + XCTAssertEqual(hsb.b, 0.0, accuracy: 0.001) + } + + func test_toHSBComponents_white_brightnessIsOne() { + let white = NSColor(hex: "FFFFFF") + let hsb = white.toHSBComponents() + XCTAssertEqual(hsb.b, 1.0, accuracy: 0.001) + } + + // MARK: - toHSBString(style:) + + func test_toHSBString_cssStyle_red() { + let red = NSColor(hex: "FF0000") + let result = red.toHSBString(style: .css) + XCTAssertEqual(result, "hsb(0, 100%, 100%)") + } + + func test_toHSBString_designStyle_noPercentSigns() { + let red = NSColor(hex: "FF0000") + let result = red.toHSBString(style: .design) + XCTAssertFalse(result.contains("%")) + } + + func test_toHSBString_swiftUIStyle_containsColorHue() { + let red = NSColor(hex: "FF0000") + let result = red.toHSBString(style: .swiftUI) + XCTAssertTrue(result.contains("Color(hue:")) + } + + func test_toHSBString_unformattedStyle_noParens() { + let red = NSColor(hex: "FF0000") + let result = red.toHSBString(style: .unformatted) + XCTAssertFalse(result.contains("hsb(")) + XCTAssertFalse(result.contains("(")) + } + + // MARK: - toHSLComponents() + + func test_toHSLComponents_red_correctValues() { + let red = NSColor(hex: "FF0000") + let hsl = red.toHSLComponents() + XCTAssertEqual(hsl.h, 0.0, accuracy: 0.01) // hue = 0° + XCTAssertEqual(hsl.s, 1.0, accuracy: 0.01) // saturation = 100% + XCTAssertEqual(hsl.l, 0.5, accuracy: 0.01) // lightness = 50% + } + + func test_toHSLComponents_black_allZero() { + let black = NSColor(hex: "000000") + let hsl = black.toHSLComponents() + XCTAssertEqual(hsl.h, 0.0, accuracy: 0.001) + XCTAssertEqual(hsl.s, 0.0, accuracy: 0.001) + XCTAssertEqual(hsl.l, 0.0, accuracy: 0.001) + } + + func test_toHSLComponents_white_lightnessIsOne() { + let white = NSColor(hex: "FFFFFF") + let hsl = white.toHSLComponents() + XCTAssertEqual(hsl.l, 1.0, accuracy: 0.001) + } + + func test_toHSLComponents_midGray_saturationIsZero() { + let gray = NSColor(hex: "808080") + let hsl = gray.toHSLComponents() + XCTAssertEqual(hsl.s, 0.0, accuracy: 0.01) + XCTAssertEqual(hsl.l, 0.5, accuracy: 0.02) + } + + // MARK: - toHSLString(style:) + + func test_toHSLString_cssStyle_red() { + let red = NSColor(hex: "FF0000") + let result = red.toHSLString(style: .css) + XCTAssertEqual(result, "hsl(0, 100%, 50%)") + } + + func test_toHSLString_designStyle_noPercentSigns() { + let red = NSColor(hex: "FF0000") + let result = red.toHSLString(style: .design) + XCTAssertFalse(result.contains("%")) + XCTAssertTrue(result.hasPrefix("hsl(")) + } + + func test_toHSLString_unformattedStyle_numbersOnly() { + let red = NSColor(hex: "FF0000") + let result = red.toHSLString(style: .unformatted) + XCTAssertFalse(result.contains("hsl")) + XCTAssertFalse(result.contains("%")) + } + + func test_toHSLString_returnsNonEmptyString() { + let color = NSColor(hex: "3A7BD5") + XCTAssertFalse(color.toHSLString().isEmpty) + } +} diff --git a/PikaTests/NSColorHexTests.swift b/PikaTests/NSColorHexTests.swift new file mode 100644 index 00000000..d0e5123e --- /dev/null +++ b/PikaTests/NSColorHexTests.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import Pika + +final class NSColorHexTests: XCTestCase { + // MARK: - roundToHex() + + func test_roundToHex_zero_returnsZero() { + XCTAssertEqual(NSColor.white.roundToHex(0.0), 0) + } + + func test_roundToHex_negativeValue_returnsZero() { + XCTAssertEqual(NSColor.white.roundToHex(-0.5), 0) + } + + func test_roundToHex_one_returns255() { + XCTAssertEqual(NSColor.white.roundToHex(1.0), 255) + } + + func test_roundToHex_half_returns128() { + // 0.5 * 255 = 127.5 → rounds to 128 + XCTAssertEqual(NSColor.white.roundToHex(0.5), 128) + } + + func test_roundToHex_roundsCorrectly() { + // 100/255 ≈ 0.392156... → round(0.392156 * 255) = round(100.0) = 100 + XCTAssertEqual(NSColor.white.roundToHex(100.0 / 255.0), 100) + } + + // MARK: - toHex() + + func test_toHex_red() { + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(red.toHex(), 0xFF0000) + } + + func test_toHex_green() { + let green = NSColor(red: 0, green: 1, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(green.toHex(), 0x00FF00) + } + + func test_toHex_blue() { + let blue = NSColor(red: 0, green: 0, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(blue.toHex(), 0x0000FF) + } + + func test_toHex_black() { + let black = NSColor(red: 0, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(black.toHex(), 0x000000) + } + + func test_toHex_white() { + let white = NSColor(red: 1, green: 1, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(white.toHex(), 0xFFFFFF) + } + + // MARK: - toHexString(style:) + + func test_toHexString_cssStyle_includesHash() { + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(red.toHexString(style: .css), "#ff0000") + } + + func test_toHexString_unformattedStyle_noHash() { + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(red.toHexString(style: .unformatted), "ff0000") + } + + func test_toHexString_defaultStyle_isCss() { + let blue = NSColor(red: 0, green: 0, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(blue.toHexString(), "#0000ff") + } + + func test_toHexString_white() { + let white = NSColor(red: 1, green: 1, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(white.toHexString(style: .css), "#ffffff") + } + + func test_toHexString_black() { + let black = NSColor(red: 0, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(black.toHexString(style: .css), "#000000") + } + + func test_toHexString_isLowercase() { + let color = NSColor(hex: "AABBCC") + XCTAssertEqual(color.toHexString(style: .css), "#aabbcc") + } + + func test_initHex_roundtrips_throughToHexString() { + let original = "#3a7bd5" + let color = NSColor(hex: original) + XCTAssertEqual(color.toHexString(style: .css), original) + } +} diff --git a/PikaTests/NSColorInitTests.swift b/PikaTests/NSColorInitTests.swift new file mode 100644 index 00000000..2cac265b --- /dev/null +++ b/PikaTests/NSColorInitTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import Pika + +final class NSColorInitTests: XCTestCase { + // MARK: - init(r:g:b:a:) — 255-based values + + func test_initRGB_255Based_normalizesComponents() { + let color = NSColor(r: 255, g: 128, b: 0) + let rgba = color.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 1.0, accuracy: 0.01) + XCTAssertEqual(rgba.g, 128.0 / 255.0, accuracy: 0.01) + XCTAssertEqual(rgba.b, 0.0, accuracy: 0.01) + XCTAssertEqual(rgba.a, 1.0, accuracy: 0.01) + } + + func test_initRGB_255Based_withAlpha() { + let color = NSColor(r: 0, g: 0, b: 255, a: 0.5) + let rgba = color.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.b, 1.0, accuracy: 0.01) + XCTAssertEqual(rgba.a, 0.5, accuracy: 0.01) + } + + // MARK: - init(r:g:b:a:) — 0–1 based values + + func test_initRGB_normalizedValues_usedDirectly() { + let color = NSColor(r: 0.5, g: 0.25, b: 0.75) + let rgba = color.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 0.5, accuracy: 0.01) + XCTAssertEqual(rgba.g, 0.25, accuracy: 0.01) + XCTAssertEqual(rgba.b, 0.75, accuracy: 0.01) + } + + func test_initRGB_black() { + let color = NSColor(r: 0, g: 0, b: 0) + let rgba = color.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 0.0, accuracy: 0.001) + XCTAssertEqual(rgba.g, 0.0, accuracy: 0.001) + XCTAssertEqual(rgba.b, 0.0, accuracy: 0.001) + } + + // MARK: - init(hex:alpha:) + + func test_initHex_6Char_parsesCorrectly() { + let red = NSColor(hex: "FF0000") + let rgba = red.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 1.0, accuracy: 0.01) + XCTAssertEqual(rgba.g, 0.0, accuracy: 0.01) + XCTAssertEqual(rgba.b, 0.0, accuracy: 0.01) + } + + func test_initHex_withHashPrefix_stripped() { + let color = NSColor(hex: "#00FF00") + let rgba = color.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 0.0, accuracy: 0.01) + XCTAssertEqual(rgba.g, 1.0, accuracy: 0.01) + XCTAssertEqual(rgba.b, 0.0, accuracy: 0.01) + } + + func test_initHex_3Char_expandedCorrectly() { + // "F00" should expand to "FF0000" + let color = NSColor(hex: "F00") + let rgba = color.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 1.0, accuracy: 0.01) + XCTAssertEqual(rgba.g, 0.0, accuracy: 0.01) + XCTAssertEqual(rgba.b, 0.0, accuracy: 0.01) + } + + func test_initHex_withAlpha() { + let color = NSColor(hex: "0000FF", alpha: 0.5) + let rgba = color.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.b, 1.0, accuracy: 0.01) + XCTAssertEqual(rgba.a, 0.5, accuracy: 0.01) + } + + func test_initHex_black() { + let color = NSColor(hex: "000000") + let rgba = color.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r + rgba.g + rgba.b, 0.0, accuracy: 0.001) + } + + func test_initHex_white() { + let color = NSColor(hex: "FFFFFF") + let rgba = color.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 1.0, accuracy: 0.001) + XCTAssertEqual(rgba.g, 1.0, accuracy: 0.001) + XCTAssertEqual(rgba.b, 1.0, accuracy: 0.001) + } + + func test_initHex_mixedCase() { + let lower = NSColor(hex: "ff8800") + let upper = NSColor(hex: "FF8800") + let lRGBA = lower.toRGBAComponents(in: .sRGB) + let uRGBA = upper.toRGBAComponents(in: .sRGB) + XCTAssertEqual(lRGBA.r, uRGBA.r, accuracy: 0.001) + XCTAssertEqual(lRGBA.g, uRGBA.g, accuracy: 0.001) + XCTAssertEqual(lRGBA.b, uRGBA.b, accuracy: 0.001) + } +} diff --git a/PikaTests/NSColorLabTests.swift b/PikaTests/NSColorLabTests.swift new file mode 100644 index 00000000..9c6cbb08 --- /dev/null +++ b/PikaTests/NSColorLabTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import Pika + +final class NSColorLabTests: XCTestCase { + // MARK: - toOpenGLString(style:) + + func test_toOpenGLString_cssStyle_containsRGBA() { + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + let result = red.toOpenGLString(style: .css) + XCTAssertTrue(result.hasPrefix("rgba(")) + XCTAssertTrue(result.contains("1.0)")) + } + + func test_toOpenGLString_unformattedStyle_noParens() { + let color = NSColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1).usingColorSpace(.sRGB)! + let result = color.toOpenGLString(style: .unformatted) + XCTAssertFalse(result.contains("rgba(")) + } + + func test_toOpenGLString_valuesNormalized() { + // OpenGL uses 0–1 range; red should give r≈1, g≈0, b≈0 + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + let result = red.toOpenGLString(style: .css) + XCTAssertTrue(result.contains("1")) + } + + // MARK: - toLabComponents() + + func test_toLabComponents_white_LIsApproximately100() { + let white = NSColor(red: 1, green: 1, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + let lab = white.toLabComponents() + XCTAssertEqual(lab.l, 100.0, accuracy: 1.0) + } + + func test_toLabComponents_black_LIsApproximatelyZero() { + let black = NSColor(red: 0, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + let lab = black.toLabComponents() + XCTAssertEqual(lab.l, 0.0, accuracy: 1.0) + } + + func test_toLabComponents_gray_AAndBNearZero() { + // Neutral gray should have a ≈ 0, b ≈ 0 + let gray = NSColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1).usingColorSpace(.sRGB)! + let lab = gray.toLabComponents() + XCTAssertEqual(lab.a, 0.0, accuracy: 2.0) + XCTAssertEqual(lab.b, 0.0, accuracy: 2.0) + } + + func test_toLabComponents_LIsInValidRange() { + let color = NSColor(r: 100, g: 150, b: 200) + let lab = color.toLabComponents() + XCTAssertGreaterThanOrEqual(lab.l, 0.0) + XCTAssertLessThanOrEqual(lab.l, 100.0) + } + + // MARK: - toLabString(style:) + + func test_toLabString_cssStyle_containsLabPrefix() { + let color = NSColor(r: 100, g: 150, b: 200) + let result = color.toLabString(style: .css) + XCTAssertTrue(result.hasPrefix("lab("), "Expected 'lab(...)', got '\(result)'") + } + + func test_toLabString_unformattedStyle_noLabPrefix() { + let color = NSColor(r: 100, g: 150, b: 200) + let result = color.toLabString(style: .unformatted) + XCTAssertFalse(result.contains("lab(")) + } + + func test_toLabString_returnsNonEmptyString() { + let color = NSColor(r: 200, g: 100, b: 50) + XCTAssertFalse(color.toLabString().isEmpty) + } + + // MARK: - toOklchComponents() + + func test_toOklchComponents_white_LIsApproximatelyOne() { + let white = NSColor(red: 1, green: 1, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + let oklch = white.toOklchComponents() + XCTAssertEqual(oklch.l, 1.0, accuracy: 0.01) + } + + func test_toOklchComponents_black_LIsApproximatelyZero() { + let black = NSColor(red: 0, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + let oklch = black.toOklchComponents() + XCTAssertEqual(oklch.l, 0.0, accuracy: 0.01) + } + + func test_toOklchComponents_gray_chromaIsLow() { + let gray = NSColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1).usingColorSpace(.sRGB)! + let oklch = gray.toOklchComponents() + XCTAssertLessThan(oklch.c, 0.01) + } + + func test_toOklchComponents_LIsInValidRange() { + let color = NSColor(r: 100, g: 150, b: 200) + let oklch = color.toOklchComponents() + XCTAssertGreaterThanOrEqual(oklch.l, 0.0) + XCTAssertLessThanOrEqual(oklch.l, 1.0) + } + + // MARK: - toOklchString(style:) + + func test_toOklchString_cssStyle_containsOklchPrefix() { + let color = NSColor(r: 100, g: 150, b: 200) + let result = color.toOklchString(style: .css) + XCTAssertTrue(result.hasPrefix("oklch("), "Expected 'oklch(...)', got '\(result)'") + } + + func test_toOklchString_returnsNonEmptyString() { + let color = NSColor(r: 50, g: 100, b: 200) + XCTAssertFalse(color.toOklchString().isEmpty) + } +} diff --git a/PikaTests/NSColorLuminanceTests.swift b/PikaTests/NSColorLuminanceTests.swift new file mode 100644 index 00000000..e75929c3 --- /dev/null +++ b/PikaTests/NSColorLuminanceTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import Pika + +final class NSColorLuminanceTests: XCTestCase { + // MARK: - clip() + + func test_clip_belowMinimum_returnsMinimum() { + let color = NSColor.white + XCTAssertEqual(color.clip(-5, 0, 10), 0) + } + + func test_clip_aboveMaximum_returnsMaximum() { + let color = NSColor.white + XCTAssertEqual(color.clip(15, 0, 10), 10) + } + + func test_clip_withinRange_returnsValue() { + let color = NSColor.white + XCTAssertEqual(color.clip(5, 0, 10), 5) + } + + func test_clip_atMinimum_returnsMinimum() { + let color = NSColor.white + XCTAssertEqual(color.clip(0, 0, 10), 0) + } + + func test_clip_atMaximum_returnsMaximum() { + let color = NSColor.white + XCTAssertEqual(color.clip(10, 0, 10), 10) + } + + // MARK: - luminance + + func test_luminance_white_isOne() { + let white = NSColor(r: 1, g: 1, b: 1) + XCTAssertEqual(white.luminance, 1.0, accuracy: 0.001) + } + + func test_luminance_black_isZero() { + let black = NSColor(r: 0, g: 0, b: 0) + XCTAssertEqual(black.luminance, 0.0, accuracy: 0.001) + } + + func test_luminance_midGray_isApproximatelyCorrect() { + // sRGB 128,128,128 → relative luminance ≈ 0.216 + let gray = NSColor(r: 128, g: 128, b: 128) + XCTAssertEqual(gray.luminance, 0.216, accuracy: 0.01) + } + + func test_luminance_red_matchesWCAGFormula() { + // Pure red sRGB luminance: 0.2126 * linearize(1.0) = 0.2126 + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1) + XCTAssertEqual(red.luminance, 0.2126, accuracy: 0.01) + } + + func test_luminance_green_matchesWCAGFormula() { + // Pure green sRGB luminance: 0.7152 * linearize(1.0) = 0.7152 + let green = NSColor(red: 0, green: 1, blue: 0, alpha: 1) + XCTAssertEqual(green.luminance, 0.7152, accuracy: 0.01) + } + + func test_luminance_blue_matchesWCAGFormula() { + // Pure blue sRGB luminance: 0.0722 * linearize(1.0) = 0.0722 + let blue = NSColor(red: 0, green: 0, blue: 1, alpha: 1) + XCTAssertEqual(blue.luminance, 0.0722, accuracy: 0.01) + } + + func test_luminance_isNonNegative() { + // luminance should never go negative even for edge-case values + let black = NSColor(r: 0, g: 0, b: 0) + XCTAssertGreaterThanOrEqual(black.luminance, 0.0) + } + + // MARK: - contrastRatio(with:) + + func test_contrastRatio_whiteOnBlack_is21() { + let white = NSColor(r: 1, g: 1, b: 1) + let black = NSColor(r: 0, g: 0, b: 0) + XCTAssertEqual(white.contrastRatio(with: black), 21.0, accuracy: 0.1) + } + + func test_contrastRatio_isSymmetric() { + let white = NSColor(r: 1, g: 1, b: 1) + let black = NSColor(r: 0, g: 0, b: 0) + XCTAssertEqual(white.contrastRatio(with: black), black.contrastRatio(with: white), accuracy: 0.001) + } + + func test_contrastRatio_sameColor_isOne() { + let red = NSColor(r: 255, g: 0, b: 0) + XCTAssertEqual(red.contrastRatio(with: red), 1.0, accuracy: 0.001) + } + + func test_contrastRatio_isAtLeastOne() { + let colorA = NSColor(r: 100, g: 150, b: 200) + let colorB = NSColor(r: 50, g: 80, b: 120) + XCTAssertGreaterThanOrEqual(colorA.contrastRatio(with: colorB), 1.0) + } + + // MARK: - toContrastRatioString(with:) + + func test_toContrastRatioString_whiteOnBlack_returns21() { + let white = NSColor(r: 1, g: 1, b: 1) + let black = NSColor(r: 0, g: 0, b: 0) + let result = white.toContrastRatioString(with: black) + XCTAssertTrue(result.hasPrefix("21"), "Expected '21...', got '\(result)'") + } + + func test_toContrastRatioString_returnsNumericString() { + let white = NSColor(r: 1, g: 1, b: 1) + let gray = NSColor(r: 128, g: 128, b: 128) + let result = white.toContrastRatioString(with: gray) + XCTAssertNotNil(Double(result), "Expected numeric string, got '\(result)'") + } +} diff --git a/PikaTests/NSColorRGBTests.swift b/PikaTests/NSColorRGBTests.swift new file mode 100644 index 00000000..916eb3fb --- /dev/null +++ b/PikaTests/NSColorRGBTests.swift @@ -0,0 +1,128 @@ +import XCTest +@testable import Pika + +final class NSColorRGBTests: XCTestCase { + // MARK: - toRGBAComponents(in:) + + func test_toRGBAComponents_red() { + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1) + let rgba = red.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 1.0, accuracy: 0.001) + XCTAssertEqual(rgba.g, 0.0, accuracy: 0.001) + XCTAssertEqual(rgba.b, 0.0, accuracy: 0.001) + XCTAssertEqual(rgba.a, 1.0, accuracy: 0.001) + } + + func test_toRGBAComponents_returnsAllFourChannels() { + let color = NSColor(red: 0.2, green: 0.4, blue: 0.6, alpha: 0.8) + let rgba = color.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 0.2, accuracy: 0.01) + XCTAssertEqual(rgba.g, 0.4, accuracy: 0.01) + XCTAssertEqual(rgba.b, 0.6, accuracy: 0.01) + XCTAssertEqual(rgba.a, 0.8, accuracy: 0.01) + } + + // MARK: - toRGBString(style:) + + func test_toRGBString_cssStyle_red() { + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(red.toRGBString(style: .css), "rgb(255, 0, 0)") + } + + func test_toRGBString_designStyle_noParenFormat() { + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(red.toRGBString(style: .design), "rgb(255, 0, 0)") + } + + func test_toRGBString_swiftUIStyle() { + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertTrue(red.toRGBString(style: .swiftUI).hasPrefix("Color(red:")) + } + + func test_toRGBString_unformattedStyle() { + let color = NSColor(red: 0, green: 128.0 / 255.0, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + let result = color.toRGBString(style: .unformatted) + XCTAssertFalse(result.contains("rgb(")) + XCTAssertTrue(result.contains(",")) + } + + func test_toRGBString_defaultStyle_isCss() { + let blue = NSColor(red: 0, green: 0, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(blue.toRGBString(), "rgb(0, 0, 255)") + } + + func test_toRGBString_black() { + let black = NSColor(red: 0, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(black.toRGBString(style: .css), "rgb(0, 0, 0)") + } + + func test_toRGBString_white() { + let white = NSColor(red: 1, green: 1, blue: 1, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(white.toRGBString(style: .css), "rgb(255, 255, 255)") + } + + // MARK: - toRGB8BitArray() + + func test_toRGB8BitArray_red() { + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(red.toRGB8BitArray(), [255, 0, 0]) + } + + func test_toRGB8BitArray_returnsThreeElements() { + let color = NSColor(r: 100, g: 150, b: 200) + let result = color.toRGB8BitArray() + XCTAssertEqual(result.count, 3) + } + + func test_toRGB8BitArray_valuesInRange() { + let color = NSColor(r: 64, g: 128, b: 192) + let result = color.toRGB8BitArray() + XCTAssertTrue(result.allSatisfy { $0 >= 0 && $0 <= 255 }) + } + + // MARK: - toFormat(format:style:) + + func test_toFormat_hex_delegatesToHexString() { + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(red.toFormat(format: .hex, style: .css), "#ff0000") + } + + func test_toFormat_rgb_delegatesToRGBString() { + let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(red.toFormat(format: .rgb, style: .css), "rgb(255, 0, 0)") + } + + func test_toFormat_returnsNonEmptyStringForAllFormats() { + let color = NSColor(r: 100, g: 150, b: 200) + for format in ColorFormat.allCases { + let result = color.toFormat(format: format) + XCTAssertFalse(result.isEmpty, "toFormat(\(format)) returned empty string") + } + } + + // MARK: - getUIColor() + + func test_getUIColor_darkColor_returnsSwiftUIWhite() { + let black = NSColor(r: 0, g: 0, b: 0) + let uiColor: SwiftUI.Color = black.getUIColor() + XCTAssertEqual(uiColor, .white) + } + + func test_getUIColor_lightColor_returnsSwiftUIBlack() { + let white = NSColor(r: 1, g: 1, b: 1) + let uiColor: SwiftUI.Color = white.getUIColor() + XCTAssertEqual(uiColor, .black) + } + + func test_getUIColor_darkColor_returnsNSColorWhite() { + let black = NSColor(r: 0, g: 0, b: 0) + let nsColor: NSColor = black.getUIColor() + XCTAssertEqual(nsColor, .white) + } + + func test_getUIColor_lightColor_returnsNSColorBlack() { + let white = NSColor(r: 1, g: 1, b: 1) + let nsColor: NSColor = white.getUIColor() + XCTAssertEqual(nsColor, .black) + } +} diff --git a/PikaTests/NotificationNamesTests.swift b/PikaTests/NotificationNamesTests.swift new file mode 100644 index 00000000..ff7f9a97 --- /dev/null +++ b/PikaTests/NotificationNamesTests.swift @@ -0,0 +1,103 @@ +import XCTest +@testable import Pika + +/// Verifies that every typed Notification.Name constant resolves to its +/// expected raw string value. These tests catch accidental regressions +/// if a constant's raw string changes but a subscriber still listens on +/// the old name. +final class NotificationNamesTests: XCTestCase { + func test_triggerPickForeground() { + XCTAssertEqual(Notification.Name.triggerPickForeground.rawValue, + PikaConstants.ncTriggerPickForeground) + } + + func test_triggerPickBackground() { + XCTAssertEqual(Notification.Name.triggerPickBackground.rawValue, + PikaConstants.ncTriggerPickBackground) + } + + func test_triggerCopyForeground() { + XCTAssertEqual(Notification.Name.triggerCopyForeground.rawValue, + PikaConstants.ncTriggerCopyForeground) + } + + func test_triggerCopyBackground() { + XCTAssertEqual(Notification.Name.triggerCopyBackground.rawValue, + PikaConstants.ncTriggerCopyBackground) + } + + func test_triggerCopyText() { + XCTAssertEqual(Notification.Name.triggerCopyText.rawValue, + PikaConstants.ncTriggerCopyText) + } + + func test_triggerCopyData() { + XCTAssertEqual(Notification.Name.triggerCopyData.rawValue, + PikaConstants.ncTriggerCopyData) + } + + func test_triggerSystemPickerForeground() { + XCTAssertEqual(Notification.Name.triggerSystemPickerForeground.rawValue, + PikaConstants.ncTriggerSystemPickerForeground) + } + + func test_triggerSystemPickerBackground() { + XCTAssertEqual(Notification.Name.triggerSystemPickerBackground.rawValue, + PikaConstants.ncTriggerSystemPickerBackground) + } + + func test_triggerSwap() { + XCTAssertEqual(Notification.Name.triggerSwap.rawValue, + PikaConstants.ncTriggerSwap) + } + + func test_triggerUndo() { + XCTAssertEqual(Notification.Name.triggerUndo.rawValue, + PikaConstants.ncTriggerUndo) + } + + func test_triggerRedo() { + XCTAssertEqual(Notification.Name.triggerRedo.rawValue, + PikaConstants.ncTriggerRedo) + } + + func test_triggerPreferences() { + XCTAssertEqual(Notification.Name.triggerPreferences.rawValue, + PikaConstants.ncTriggerPreferences) + } + + func test_triggerFormatHex() { + XCTAssertEqual(Notification.Name.triggerFormatHex.rawValue, + PikaConstants.ncTriggerFormatHex) + } + + func test_triggerFormatRGB() { + XCTAssertEqual(Notification.Name.triggerFormatRGB.rawValue, + PikaConstants.ncTriggerFormatRGB) + } + + func test_triggerFormatHSB() { + XCTAssertEqual(Notification.Name.triggerFormatHSB.rawValue, + PikaConstants.ncTriggerFormatHSB) + } + + func test_triggerFormatHSL() { + XCTAssertEqual(Notification.Name.triggerFormatHSL.rawValue, + PikaConstants.ncTriggerFormatHSL) + } + + func test_triggerFormatOpenGL() { + XCTAssertEqual(Notification.Name.triggerFormatOpenGL.rawValue, + PikaConstants.ncTriggerFormatOpenGL) + } + + func test_triggerFormatLAB() { + XCTAssertEqual(Notification.Name.triggerFormatLAB.rawValue, + PikaConstants.ncTriggerFormatLAB) + } + + func test_triggerFormatOKLCH() { + XCTAssertEqual(Notification.Name.triggerFormatOKLCH.rawValue, + PikaConstants.ncTriggerFormatOKLCH) + } +} diff --git a/PikaTests/WCAGComplianceTests.swift b/PikaTests/WCAGComplianceTests.swift new file mode 100644 index 00000000..b7b7ac76 --- /dev/null +++ b/PikaTests/WCAGComplianceTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import Pika + +final class WCAGComplianceTests: XCTestCase { + // White on black — contrast 21:1 — all levels pass + func test_whiteOnBlack_allLevelPass() { + let white = NSColor(r: 1, g: 1, b: 1) + let black = NSColor(r: 0, g: 0, b: 0) + let result = white.WCAGCompliance(with: black) + XCTAssertTrue(result.ratio30) + XCTAssertTrue(result.ratio45) + XCTAssertTrue(result.ratio70) + } + + // Same color — contrast 1:1 — all levels fail + func test_sameColor_allLevelsFail() { + let gray = NSColor(r: 128, g: 128, b: 128) + let result = gray.WCAGCompliance(with: gray) + XCTAssertFalse(result.ratio30) + XCTAssertFalse(result.ratio45) + XCTAssertFalse(result.ratio70) + } + + // Contrast ≥ 3:1 but < 4.5:1 — only ratio30 passes + func test_lowContrast_onlyRatio30Passes() { + // Dark gray on light gray: empirically ~3.95:1 + let lightGray = NSColor(r: 200, g: 200, b: 200) + let darkGray = NSColor(r: 90, g: 90, b: 90) + let result = lightGray.WCAGCompliance(with: darkGray) + XCTAssertTrue(result.ratio30) + XCTAssertFalse(result.ratio45) + XCTAssertFalse(result.ratio70) + } + + // Contrast ≥ 4.5:1 but < 7:1 — ratio30 and ratio45 pass + func test_mediumContrast_ratio30And45Pass() { + // Black text on mid-light background: ~5.7:1 + let background = NSColor(r: 180, g: 180, b: 180) + let text = NSColor(r: 0, g: 0, b: 0) + let result = background.WCAGCompliance(with: text) + XCTAssertTrue(result.ratio30) + XCTAssertTrue(result.ratio45) + XCTAssertFalse(result.ratio70) + } + + // toWCAGCompliance is an alias — results should be identical + func test_toWCAGCompliance_equivalentToWCAGCompliance() { + let white = NSColor(r: 1, g: 1, b: 1) + let black = NSColor(r: 0, g: 0, b: 0) + let direct = white.WCAGCompliance(with: black) + let alias = white.toWCAGCompliance(with: black) + XCTAssertEqual(direct.ratio30, alias.ratio30) + XCTAssertEqual(direct.ratio45, alias.ratio45) + XCTAssertEqual(direct.ratio70, alias.ratio70) + } + + // isSymmetric — contrast is the same regardless of order + func test_WCAGCompliance_isSymmetric() { + let a = NSColor(r: 50, g: 100, b: 200) + let b = NSColor(r: 220, g: 220, b: 220) + let ab = a.WCAGCompliance(with: b) + let ba = b.WCAGCompliance(with: a) + XCTAssertEqual(ab.ratio30, ba.ratio30) + XCTAssertEqual(ab.ratio45, ba.ratio45) + XCTAssertEqual(ab.ratio70, ba.ratio70) + } + + // Ratio levels are cumulative — ratio70 can't pass without ratio45 + func test_WCAGCompliance_levelOrder_isCumulative() { + let colors: [NSColor] = [ + NSColor(r: 0, g: 0, b: 0), + NSColor(r: 50, g: 50, b: 50), + NSColor(r: 128, g: 128, b: 128), + NSColor(r: 200, g: 200, b: 200), + NSColor(r: 255, g: 255, b: 255), + ] + let white = NSColor(r: 255, g: 255, b: 255) + for color in colors { + let result = color.WCAGCompliance(with: white) + if result.ratio70 { XCTAssertTrue(result.ratio45) } + if result.ratio45 { XCTAssertTrue(result.ratio30) } + } + } +} From b388de815f346592e19a07089233feb92e5d4ed2 Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 15:55:28 -0700 Subject: [PATCH 05/18] Fix test compilation errors - Add default = .css to toLabString(style:) and toOklchString(style:) (these were missing default values unlike all other format functions) - Add missing import SwiftUI to NSColorRGBTests.swift Co-Authored-By: Claude Sonnet 4.6 --- Pika/Extensions/NSColor+Lab.swift | 4 ++-- PikaTests/NSColorRGBTests.swift | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Pika/Extensions/NSColor+Lab.swift b/Pika/Extensions/NSColor+Lab.swift index f46c2ad2..9a4d2d71 100644 --- a/Pika/Extensions/NSColor+Lab.swift +++ b/Pika/Extensions/NSColor+Lab.swift @@ -73,7 +73,7 @@ extension NSColor { return (l: L_star, a: a_star, b: b_star) } - func toLabString(style: CopyFormat) -> String { + func toLabString(style: CopyFormat = .css) -> String { let lab = toLabComponents() let l_val = round(lab.l * 100) / 100 let a_val = round(lab.a * 100) / 100 @@ -118,7 +118,7 @@ extension NSColor { return (l: L, c: C, h: H) } - func toOklchString(style: CopyFormat) -> String { + func toOklchString(style: CopyFormat = .css) -> String { let oklch = toOklchComponents() let l_val = round(oklch.l * 10000) / 100 let c_val = round(oklch.c * 10000) / 10000 diff --git a/PikaTests/NSColorRGBTests.swift b/PikaTests/NSColorRGBTests.swift index 916eb3fb..17330666 100644 --- a/PikaTests/NSColorRGBTests.swift +++ b/PikaTests/NSColorRGBTests.swift @@ -1,3 +1,4 @@ +import SwiftUI import XCTest @testable import Pika From 355459ee5b6aeb954001925fa89bd2c39c04684b Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 16:46:21 -0700 Subject: [PATCH 06/18] Fix CI: split build-for-testing from test-without-building Xcode 15+ treats any script phase stderr output as a test failure, even when the script exits 0. The LaunchAtLogin copy-helper script outputs a keychain error in CI (no signing cert), which caused xcodebuild to abort before running any tests. Split into two steps: - build-for-testing: compiles everything, tolerates script stderr - test-without-building: runs tests via .xctestrun, no script phases Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/tests.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 15e82186..8799803c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,19 +23,35 @@ jobs: -project Pika.xcodeproj \ -scheme Pika - - name: Build and test + # Build separately so script phase stderr (LaunchAtLogin keychain warning) + # doesn't prevent tests from running — Xcode 15+ fails the whole test action + # if any script phase emits to stderr, even with exit code 0. + - name: Build for testing run: | - xcodebuild test \ + set -o pipefail + xcodebuild build-for-testing \ -project Pika.xcodeproj \ -scheme Pika \ -destination 'platform=macOS' \ + -derivedDataPath build/DerivedData \ CODE_SIGNING_ALLOWED=NO \ - | xcpretty --color && exit ${PIPESTATUS[0]} + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + 2>&1 | xcpretty --color + + - name: Run tests + run: | + set -o pipefail + XCTESTRUN=$(find build/DerivedData -name "Pika_*.xctestrun" | head -1) + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN" \ + -destination 'platform=macOS' \ + 2>&1 | xcpretty --color - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results - path: ~/Library/Developer/Xcode/DerivedData/**/Logs/Test/*.xcresult + path: build/DerivedData/**/Logs/Test/*.xcresult retention-days: 7 From 619f31155b1e69f61f63bda7916e1fe47aec0421 Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 17:02:36 -0700 Subject: [PATCH 07/18] Fix CI: broaden xctestrun search and show raw test output Remove xcpretty from test-without-building so failures are visible in CI logs. Broaden .xctestrun glob pattern and add diagnostic output when the file is not found. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/tests.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8799803c..ed26ad35 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,11 +42,16 @@ jobs: - name: Run tests run: | set -o pipefail - XCTESTRUN=$(find build/DerivedData -name "Pika_*.xctestrun" | head -1) + XCTESTRUN=$(find build/DerivedData -name "*.xctestrun" | head -1) + echo "Using xctestrun: $XCTESTRUN" + if [ -z "$XCTESTRUN" ]; then + echo "Error: no .xctestrun file found" + find build/DerivedData -type f -name "*.xctestrun" || true + exit 1 + fi xcodebuild test-without-building \ -xctestrun "$XCTESTRUN" \ - -destination 'platform=macOS' \ - 2>&1 | xcpretty --color + -destination 'platform=macOS' - name: Upload test results if: always() From b5cbb1141d2c8674482a53bfa692c9b1c0e61747 Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 17:03:55 -0700 Subject: [PATCH 08/18] Add workflow_dispatch trigger to allow manual test runs Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ed26ad35..e7785a69 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,7 @@ on: pull_request: push: branches: [main] + workflow_dispatch: jobs: test: From 83d46deaaf8b98e77464eab9443194c05e829b44 Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 17:09:13 -0700 Subject: [PATCH 09/18] Fix WCAGComplianceTests: use gray with contrast in 4.5-7:1 range rgb(180,180,180) on black gives ~10:1 (passes ratio70), not ~5.7:1. Use rgb(120,120,120) on black which gives ~4.76:1 as intended. Co-Authored-By: Claude Sonnet 4.6 --- PikaTests/WCAGComplianceTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PikaTests/WCAGComplianceTests.swift b/PikaTests/WCAGComplianceTests.swift index b7b7ac76..e3d3ee18 100644 --- a/PikaTests/WCAGComplianceTests.swift +++ b/PikaTests/WCAGComplianceTests.swift @@ -34,8 +34,8 @@ final class WCAGComplianceTests: XCTestCase { // Contrast ≥ 4.5:1 but < 7:1 — ratio30 and ratio45 pass func test_mediumContrast_ratio30And45Pass() { - // Black text on mid-light background: ~5.7:1 - let background = NSColor(r: 180, g: 180, b: 180) + // rgb(120,120,120) on black: luminance ≈ 0.188 → contrast ≈ 4.76:1 + let background = NSColor(r: 120, g: 120, b: 120) let text = NSColor(r: 0, g: 0, b: 0) let result = background.WCAGCompliance(with: text) XCTAssertTrue(result.ratio30) From 3693d4db0901886f0c91048c1dccd6492c8c6ef5 Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 17:45:27 -0700 Subject: [PATCH 10/18] Refactor: slim AppDelegate, fix swiftlint, reorganise folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppDelegate (488→225 lines, 4 swiftlint disables removed): - Extract URLSchemeHandler — handles pika:// URL scheme dispatch, replacing the cyclomatic_complexity/function_body_length suppressions - Extract WindowCoordinator — owns all window creation and visibility - Extract StatusBarController — owns status bar setup, visibility, and click handling (now observes Defaults internally) - AppDelegate reduced to thin lifecycle + notification dispatch layer PreferencesView (2 swiftlint disables removed): - Replace 3-element tuple return from getColorSpaces() with a private ColorSpaceConfig struct, removing large_tuple and opening_brace disables Folder reorganisation: - Utilities/ → Services/ (rename + 3 new files added) - Styles/ → ButtonStyles/ (rename) - Views/ Xcode groups reorganised into logical subgroups: Eyedropper, Compliance, Preferences, About, Navigation, Shared - Services/ Xcode groups: Models, Export, Windows, ColorNames, StatusBar, URLHandling, Modifiers All 6 previously-suppressed swiftlint rules now pass without disables. Remaining disables (identifier_name in math code, line_length for long strings) are legitimate and unchanged. Co-Authored-By: Claude Sonnet 4.6 --- Pika.xcodeproj/project.pbxproj | 201 ++++++++--- Pika/AppDelegate.swift | 314 ++---------------- .../AppearanceButtonStyle.swift | 0 .../CircleButtonStyle.swift | 0 .../EyedropperButtonStyle.swift | 0 .../SwapButtonStyle.swift | 0 .../ClosestVector.swift | 0 .../ColorPickOverlayWindow.swift | 0 Pika/{Utilities => Services}/Exporter.swift | 0 .../{Utilities => Services}/Eyedroppers.swift | 0 Pika/{Utilities => Services}/LoadColors.swift | 0 .../OverflowContentViewModifier.swift | 0 Pika/{Utilities => Services}/PikaWindow.swift | 0 Pika/Services/StatusBarController.swift | 88 +++++ Pika/Services/URLSchemeHandler.swift | 62 ++++ Pika/Services/WindowCoordinator.swift | 131 ++++++++ Pika/Views/PreferencesView.swift | 18 +- PikaTests/APCAComplianceTests.swift | 2 +- PikaTests/NSColorHSLTests.swift | 8 +- PikaTests/NSColorHexTests.swift | 2 +- PikaTests/NSColorInitTests.swift | 2 +- PikaTests/NSColorLabTests.swift | 2 +- PikaTests/NSColorLuminanceTests.swift | 2 +- PikaTests/NSColorRGBTests.swift | 2 +- PikaTests/NotificationNamesTests.swift | 2 +- PikaTests/WCAGComplianceTests.swift | 2 +- 26 files changed, 495 insertions(+), 343 deletions(-) rename Pika/{Styles => ButtonStyles}/AppearanceButtonStyle.swift (100%) rename Pika/{Styles => ButtonStyles}/CircleButtonStyle.swift (100%) rename Pika/{Styles => ButtonStyles}/EyedropperButtonStyle.swift (100%) rename Pika/{Styles => ButtonStyles}/SwapButtonStyle.swift (100%) rename Pika/{Utilities => Services}/ClosestVector.swift (100%) rename Pika/{Utilities => Services}/ColorPickOverlayWindow.swift (100%) rename Pika/{Utilities => Services}/Exporter.swift (100%) rename Pika/{Utilities => Services}/Eyedroppers.swift (100%) rename Pika/{Utilities => Services}/LoadColors.swift (100%) rename Pika/{Utilities => Services}/OverflowContentViewModifier.swift (100%) rename Pika/{Utilities => Services}/PikaWindow.swift (100%) create mode 100644 Pika/Services/StatusBarController.swift create mode 100644 Pika/Services/URLSchemeHandler.swift create mode 100644 Pika/Services/WindowCoordinator.swift diff --git a/Pika.xcodeproj/project.pbxproj b/Pika.xcodeproj/project.pbxproj index 52f44778..c42a39a7 100644 --- a/Pika.xcodeproj/project.pbxproj +++ b/Pika.xcodeproj/project.pbxproj @@ -7,6 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + CC1100000000000000000200 /* URLSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1100000000000000000100 /* URLSchemeHandler.swift */; }; + CC1100000000000000000201 /* WindowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1100000000000000000101 /* WindowCoordinator.swift */; }; + CC1100000000000000000202 /* StatusBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1100000000000000000102 /* StatusBarController.swift */; }; + CC1100000000000000000203 /* URLSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1100000000000000000100 /* URLSchemeHandler.swift */; }; + CC1100000000000000000204 /* WindowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1100000000000000000101 /* WindowCoordinator.swift */; }; + CC1100000000000000000205 /* StatusBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1100000000000000000102 /* StatusBarController.swift */; }; + CC20000000000000000000F2 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC20000000000000000000D2 /* XCTest.framework */; }; CC2000000000000000000101 /* NSColorInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000100 /* NSColorInitTests.swift */; }; CC2000000000000000000201 /* NSColorLuminanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000200 /* NSColorLuminanceTests.swift */; }; @@ -172,6 +179,10 @@ /* End PBXTargetDependency section */ /* Begin PBXFileReference section */ + CC1100000000000000000100 /* URLSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeHandler.swift; sourceTree = ""; }; + CC1100000000000000000101 /* WindowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCoordinator.swift; sourceTree = ""; }; + CC1100000000000000000102 /* StatusBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarController.swift; sourceTree = ""; }; + CC20000000000000000000D2 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_FRAMEWORKS_DIR; }; CC20000000000000000000E2 /* PikaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PikaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CC2000000000000000000100 /* NSColorInitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSColorInitTests.swift; sourceTree = ""; }; @@ -377,9 +388,9 @@ EA72BB8125A5334B008205E7 /* Metal */, EAD0B6D8259CED1D00FA2F67 /* Pika.entitlements */, EAD0B6D1259CED1D00FA2F67 /* Preview Content */, - EAD0B71A259D14C200FA2F67 /* Styles */, + EAD0B71A259D14C200FA2F67 /* ButtonStyles */, 22F23B6925CEE1E600064E62 /* TouchBar */, - EAD0B6F0259CF29300FA2F67 /* Utilities */, + EAD0B6F0259CF29300FA2F67 /* Services */, EAD0B6F2259CF29300FA2F67 /* Views */, ); path = Pika; @@ -415,54 +426,37 @@ path = Extensions; sourceTree = ""; }; - EAD0B6F0259CF29300FA2F67 /* Utilities */ = { + EAD0B6F0259CF29300FA2F67 /* Services */ = { isa = PBXGroup; children = ( - EA7B199B25FBA0E600E06D9D /* ClosestVector.swift */, - F8ABAC5C2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift */, - EACA8A44260501210064035C /* Exporter.swift */, - EAD0B6F1259CF29300FA2F67 /* Eyedroppers.swift */, - EA7B199525FBA08100E06D9D /* LoadColors.swift */, - EA0C526325AB5D1700AFF716 /* PikaWindow.swift */, - 22D28DB62862377F00FC7DD4 /* OverflowContentViewModifier.swift */, + CC1100000000000000000007 /* Models */, + CC1100000000000000000008 /* Export */, + CC1100000000000000000009 /* Windows */, + CC110000000000000000000A /* ColorNames */, + CC110000000000000000000B /* StatusBar */, + CC110000000000000000000C /* URLHandling */, + CC110000000000000000000D /* Modifiers */, ); - path = Utilities; + name = Services; + path = Services; sourceTree = ""; }; EAD0B6F2259CF29300FA2F67 /* Views */ = { isa = PBXGroup; children = ( - EAD0B6F3259CF29300FA2F67 /* AboutView.swift */, - 220D5E9328DB154300B6285E /* AppModeButtons.swift */, - 220D5E9728DB158400B6285E /* AppModeToggleGroup.swift */, - EAD0B6F5259CF29300FA2F67 /* AppVersion.swift */, - 22903B0228294F49004BB9F0 /* ColorExampleRow.swift */, - EA635DE025B4FC580014D91A /* ColorPickers.swift */, - F8ABAC592EAAD0DF008CD152 /* ColorPickOverlay.swift */, - EABAEADF284D50D1000716AE /* ComplianceButtons.swift */, - EA801284259F8F480026D5D9 /* ComplianceToggle.swift */, - EA424C7C25CDEF98009056A9 /* ComplianceToggleGroup.swift */, EAD0B6F6259CF29300FA2F67 /* ContentView.swift */, - EA0C526E25AB683400AFF716 /* EyedropperButton.swift */, - EA635DE925B534C80014D91A /* EyedropperItem.swift */, EA8124C0259E307D00033F0B /* Footer.swift */, - 221600F825A62E5B00B8B7D9 /* IconImage.swift */, - EAA8AE1B25B8F03B0049299B /* KeyboardShortcutGrid.swift */, - 22FE80B225BA0F820063759E /* KeyboardShortcutItem.swift */, - EAA8AE1825B8EC070049299B /* KeyboardShortcutKey.swift */, - EAD0B71B259D151400FA2F67 /* NavigationMenu.swift */, - EA0C525F25AB5A2B00AFF716 /* NavigationMenuItems.swift */, - EAD0B6F4259CF29300FA2F67 /* PreferencesView.swift */, - EA72BB8725A53750008205E7 /* SplashView.swift */, - EA635DF025B5A6D80014D91A /* Toast.swift */, - EAF100CC25C785C4006E1EC3 /* TouchBarVisual.swift */, - 226FD60F25A940F90021A67F /* VisualEffect.swift */, - EA0C524F25AA729300AFF716 /* Visualisation.swift */, + CC1100000000000000000001 /* Eyedropper */, + CC1100000000000000000002 /* Compliance */, + CC1100000000000000000003 /* Preferences */, + CC1100000000000000000004 /* About */, + CC1100000000000000000005 /* Navigation */, + CC1100000000000000000006 /* Shared */, ); path = Views; sourceTree = ""; }; - EAD0B71A259D14C200FA2F67 /* Styles */ = { + EAD0B71A259D14C200FA2F67 /* ButtonStyles */ = { isa = PBXGroup; children = ( EAD0B717259D146200FA2F67 /* EyedropperButtonStyle.swift */, @@ -470,7 +464,8 @@ EA257BD025D8629300C3FC54 /* SwapButtonStyle.swift */, EAEBF64625E878A5002999D1 /* CircleButtonStyle.swift */, ); - path = Styles; + name = ButtonStyles; + path = ButtonStyles; sourceTree = ""; }; CC20000000000000000000C2 /* PikaTests */ = { @@ -489,6 +484,132 @@ path = PikaTests; sourceTree = ""; }; + + CC1100000000000000000001 /* Eyedropper */ = { + isa = PBXGroup; + children = ( + EA635DE025B4FC580014D91A /* ColorPickers.swift */, + EA0C526E25AB683400AFF716 /* EyedropperButton.swift */, + EA635DE925B534C80014D91A /* EyedropperItem.swift */, + ); + name = Eyedropper; + sourceTree = ""; + }; + CC1100000000000000000002 /* Compliance */ = { + isa = PBXGroup; + children = ( + EABAEADF284D50D1000716AE /* ComplianceButtons.swift */, + EA801284259F8F480026D5D9 /* ComplianceToggle.swift */, + EA424C7C25CDEF98009056A9 /* ComplianceToggleGroup.swift */, + ); + name = Compliance; + sourceTree = ""; + }; + CC1100000000000000000003 /* Preferences */ = { + isa = PBXGroup; + children = ( + 220D5E9328DB154300B6285E /* AppModeButtons.swift */, + 220D5E9728DB158400B6285E /* AppModeToggleGroup.swift */, + 22903B0228294F49004BB9F0 /* ColorExampleRow.swift */, + EAD0B6F4259CF29300FA2F67 /* PreferencesView.swift */, + ); + name = Preferences; + sourceTree = ""; + }; + CC1100000000000000000004 /* About */ = { + isa = PBXGroup; + children = ( + EAD0B6F3259CF29300FA2F67 /* AboutView.swift */, + EAA8AE1B25B8F03B0049299B /* KeyboardShortcutGrid.swift */, + 22FE80B225BA0F820063759E /* KeyboardShortcutItem.swift */, + EAA8AE1825B8EC070049299B /* KeyboardShortcutKey.swift */, + EA72BB8725A53750008205E7 /* SplashView.swift */, + ); + name = About; + sourceTree = ""; + }; + CC1100000000000000000005 /* Navigation */ = { + isa = PBXGroup; + children = ( + EAD0B71B259D151400FA2F67 /* NavigationMenu.swift */, + EA0C525F25AB5A2B00AFF716 /* NavigationMenuItems.swift */, + ); + name = Navigation; + sourceTree = ""; + }; + CC1100000000000000000006 /* Shared */ = { + isa = PBXGroup; + children = ( + EAD0B6F5259CF29300FA2F67 /* AppVersion.swift */, + F8ABAC592EAAD0DF008CD152 /* ColorPickOverlay.swift */, + 221600F825A62E5B00B8B7D9 /* IconImage.swift */, + EA635DF025B5A6D80014D91A /* Toast.swift */, + EAF100CC25C785C4006E1EC3 /* TouchBarVisual.swift */, + EA0C524F25AA729300AFF716 /* Visualisation.swift */, + 226FD60F25A940F90021A67F /* VisualEffect.swift */, + ); + name = Shared; + sourceTree = ""; + }; + CC1100000000000000000007 /* Models */ = { + isa = PBXGroup; + children = ( + EAD0B6F1259CF29300FA2F67 /* Eyedroppers.swift */, + ); + name = Models; + sourceTree = ""; + }; + CC1100000000000000000008 /* Export */ = { + isa = PBXGroup; + children = ( + EACA8A44260501210064035C /* Exporter.swift */, + ); + name = Export; + sourceTree = ""; + }; + CC1100000000000000000009 /* Windows */ = { + isa = PBXGroup; + children = ( + F8ABAC5C2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift */, + EA0C526325AB5D1700AFF716 /* PikaWindow.swift */, + CC1100000000000000000101 /* WindowCoordinator.swift */, + ); + name = Windows; + sourceTree = ""; + }; + CC110000000000000000000A /* ColorNames */ = { + isa = PBXGroup; + children = ( + EA7B199B25FBA0E600E06D9D /* ClosestVector.swift */, + EA7B199525FBA08100E06D9D /* LoadColors.swift */, + ); + name = ColorNames; + sourceTree = ""; + }; + CC110000000000000000000B /* StatusBar */ = { + isa = PBXGroup; + children = ( + CC1100000000000000000102 /* StatusBarController.swift */, + ); + name = StatusBar; + sourceTree = ""; + }; + CC110000000000000000000C /* URLHandling */ = { + isa = PBXGroup; + children = ( + CC1100000000000000000100 /* URLSchemeHandler.swift */, + ); + name = URLHandling; + sourceTree = ""; + }; + CC110000000000000000000D /* Modifiers */ = { + isa = PBXGroup; + children = ( + 22D28DB62862377F00FC7DD4 /* OverflowContentViewModifier.swift */, + ); + name = Modifiers; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -771,6 +892,9 @@ EA635DDC25B4E90B0014D91A /* BindingOnChange.swift in Sources */, EAD0B713259CFD2000FA2F67 /* Defaults.swift in Sources */, 226FD61025A940F90021A67F /* VisualEffect.swift in Sources */, + CC1100000000000000000200 /* URLSchemeHandler.swift in Sources */, + CC1100000000000000000201 /* WindowCoordinator.swift in Sources */, + CC1100000000000000000202 /* StatusBarController.swift in Sources */, EAD0B6FE259CF29C00FA2F67 /* Constants.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -837,6 +961,9 @@ EAE23DD42D032A38005BB270 /* BindingOnChange.swift in Sources */, EAE23DD52D032A38005BB270 /* Defaults.swift in Sources */, EAE23DD62D032A38005BB270 /* VisualEffect.swift in Sources */, + CC1100000000000000000203 /* URLSchemeHandler.swift in Sources */, + CC1100000000000000000204 /* WindowCoordinator.swift in Sources */, + CC1100000000000000000205 /* StatusBarController.swift in Sources */, EAE23DD72D032A38005BB270 /* Constants.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Pika/AppDelegate.swift b/Pika/AppDelegate.swift index e2cdb174..6d440362 100644 --- a/Pika/AppDelegate.swift +++ b/Pika/AppDelegate.swift @@ -8,24 +8,13 @@ import SwiftUI #endif @main -// swiftlint:disable type_body_length -// swiftlint:disable file_length -class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { - var statusBarItem: NSStatusItem! - var statusBarMenu: NSMenu! - var pikaWindow: NSWindow! - var splashWindow: NSWindow! - var aboutWindow: NSWindow! - var preferencesWindow: NSWindow! +class AppDelegate: NSObject, NSApplicationDelegate { var eyedroppers: Eyedroppers! - var undoManager = UndoManager() - var pikaTouchBarController: PikaTouchBarController! - var splashTouchBarController: SplashTouchBarController! - var aboutTouchBarController: SplashTouchBarController! - let notificationCenter = NotificationCenter.default + let windowCoordinator = WindowCoordinator() + let statusBarController = StatusBarController() func setupAppMode() { var currentMode = Defaults[.appMode] == .regular @@ -43,9 +32,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { if change.newValue == .regular { DispatchQueue.main.asyncAfter(deadline: .now()) { NSApp.unhide(self) - if let window = NSApp.windows.first { - // Verify window can become key before making it key and moving it to front if window.canBecomeKey { window.makeKeyAndOrderFront(self) } @@ -53,36 +40,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } } - self.statusBarItem.isVisible = Defaults[.hideMenuBarIcon] == false && change.newValue == .menubar } }.tieToLifetime(of: self) } - func setupStatusBar() { - // Set up status bar and menu - let statusBar = NSStatusBar.system - statusBarItem = statusBar.statusItem(withLength: CGFloat(NSStatusItem.variableLength)) - - if let button = statusBarItem.button { - button.image = NSImage(named: "StatusBarIcon") - button.action = #selector(statusBarClicked(sender:)) - button.sendAction(on: [.leftMouseUp, .rightMouseUp]) - } - - statusBarMenu = getStatusBarMenu() - - statusBarItem.isVisible = Defaults[.hideMenuBarIcon] == false && Defaults[.appMode] == .menubar - Defaults.observe(.hideMenuBarIcon) { change in - self.statusBarItem.isVisible = change.newValue == false && Defaults[.appMode] == .menubar - }.tieToLifetime(of: self) - } - func applicationWillFinishLaunching(_: Notification) { NSApp.setActivationPolicy(.prohibited) - let appleEventManager = NSAppleEventManager.shared() - appleEventManager.setEventHandler(self, andSelector: #selector(handleGetURLEvent(_:withReplyEvent:)), - forEventClass: AEEventClass(kInternetEventClass), - andEventID: AEEventID(kAEGetURL)) + NSAppleEventManager.shared().setEventHandler( + URLSchemeHandler.shared, + andSelector: #selector(URLSchemeHandler.handle(event:withReplyEvent:)), + forEventClass: AEEventClass(kInternetEventClass), + andEventID: AEEventID(kAEGetURL) + ) } func applicationDidFinishLaunching(_: Notification) { @@ -97,54 +66,39 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { #endif setupAppMode() - setupStatusBar() - // Set up eyedroppers + statusBarController.setup() + statusBarController.onToggle = { [weak self] in self?.windowCoordinator.togglePopover() } + eyedroppers = Eyedroppers() eyedroppers.foreground.undoManager = undoManager eyedroppers.background.undoManager = undoManager - // Define content view - let contentView = ContentView() - .environmentObject(eyedroppers) - .frame(minWidth: 480, - idealWidth: 480, - maxWidth: 650, - minHeight: 230, - idealHeight: 230, - maxHeight: 400, - alignment: .center) - - pikaWindow = PikaWindow.createPrimaryWindow() - pikaWindow.contentView = NSHostingView(rootView: contentView) - pikaTouchBarController = PikaTouchBarController(window: pikaWindow) - - // Define global keyboard shortcuts + windowCoordinator.setupMainWindow(eyedroppers: eyedroppers) + KeyboardShortcuts.onKeyUp(for: .togglePika) { [] in if Defaults[.viewedSplash] { NSApp.sendAction(#selector(AppDelegate.triggerPickForeground), to: nil, from: nil) } } - // Open splash window, or main if !Defaults[.viewedSplash] { openSplashWindow(nil) NSApp.activate(ignoringOtherApps: true) } - // Configure color space if !NSColorSpace.availableColorSpaces(with: .rgb).contains(Defaults[.colorSpace]) { Defaults[.colorSpace] = Defaults.Keys.colorSpace.defaultValue } if Defaults[.alwaysShowOnLaunch] { - showPika(true) + showPika(self) } } func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows: Bool) -> Bool { if !hasVisibleWindows { - pikaWindow.makeKeyAndOrderFront(self) + windowCoordinator.pikaWindow.makeKeyAndOrderFront(self) } return true } @@ -153,220 +107,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { true } - // swiftlint:disable cyclomatic_complexity - // swiftlint:disable function_body_length - @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent _: NSAppleEventDescriptor) { - if let urlString = event.forKeyword(AEKeyword(keyDirectObject))?.stringValue { - let url = URL(string: urlString) - guard url != nil, let scheme = url!.scheme, let action = url!.host else { - // some error - return - } - - var list = url!.pathComponents.dropFirst() - let task = list.popFirst() - let colorFormat = list.popFirst() - - if scheme.caseInsensitiveCompare("pika") == .orderedSame { - if colorFormat != nil { - if let format = ColorFormat.withLabel(colorFormat!) { - Defaults[.colorFormat] = format - } - } - - if action == "format" { - if let format = ColorFormat.withLabel(task!) { - Defaults[.colorFormat] = format - } - } - - if action == "pick" { - if task == "foreground" { - NSApp.sendAction(#selector(AppDelegate.triggerPickForeground(_:)), to: nil, from: nil) - } - - if task == "background" { - NSApp.sendAction(#selector(AppDelegate.triggerPickBackground(_:)), to: nil, from: nil) - } - } - - if action == "system" { - if task == "foreground" { - NSApp.sendAction(#selector(AppDelegate.triggerSystemPickerForeground(_:)), to: nil, from: nil) - } - - if task == "background" { - NSApp.sendAction(#selector(AppDelegate.triggerSystemPickerBackground(_:)), to: nil, from: nil) - } - } - - if action == "copy", task == "foreground" { - NSApp.sendAction(#selector(AppDelegate.triggerCopyForeground(_:)), to: nil, from: nil) - } - - if action == "copy", task == "background" { - NSApp.sendAction(#selector(AppDelegate.triggerCopyBackground(_:)), to: nil, from: nil) - } - - if action == "copy", task == "text" { - NSApp.sendAction(#selector(AppDelegate.triggerCopyText(_:)), to: nil, from: nil) - } - - if action == "copy", task == "json" { - NSApp.sendAction(#selector(AppDelegate.triggerCopyData(_:)), to: nil, from: nil) - } - - if action == "swap" { - NSApp.sendAction(#selector(AppDelegate.triggerSwap(_:)), to: nil, from: nil) - } - - if action == "undo" { - NSApp.sendAction(#selector(AppDelegate.triggerUndo(_:)), to: nil, from: nil) - } - - if action == "redo" { - NSApp.sendAction(#selector(AppDelegate.triggerRedo(_:)), to: nil, from: nil) - } - } - } - } - - // swiftlint:enable function_body_length - // swiftlint:enable cyclomatic_complexity - - func startMainWindow() { - if !pikaWindow.isVisible { - pikaWindow.fadeIn(nil) - } - Defaults[.viewedSplash] = true - } - - func showMainWindow() { - pikaWindow.makeKeyAndOrderFront(nil) - } + // MARK: - Window forwarding - func hideMainWindow() { - pikaWindow.orderOut(nil) - } + @objc func closeSplashWindow() { windowCoordinator.closeSplashWindow() } + @objc func togglePopover(_: AnyObject?) { windowCoordinator.togglePopover() } - @objc func closeSplashWindow() { - splashWindow.fadeOut(sender: nil, duration: 0.25, closeSelector: .close, completionHandler: startMainWindow) - } + @IBAction func openAboutWindow(_: Any?) { windowCoordinator.openAboutWindow() } + @IBAction func openPreferencesWindow(_: Any?) { windowCoordinator.openPreferencesWindow() } + @IBAction func openSplashWindow(_: Any?) { windowCoordinator.openSplashWindow() } + @IBAction func showPika(_: Any) { windowCoordinator.showPika() } + @IBAction func hidePika(_: Any) { windowCoordinator.hidePika() } - func getStatusBarMenu() -> NSMenu { - statusBarMenu = NSMenu(title: "Status Bar Menu") - statusBarMenu.delegate = self - statusBarMenu.addItem( - withTitle: PikaText.textMenuAbout, - action: #selector(openAboutWindow(_:)), - keyEquivalent: "" - ) - - statusBarMenu.addItem( - withTitle: "\(PikaText.textMenuUpdates)...", - action: #selector(checkForUpdates(_:)), - keyEquivalent: "" - ) - - statusBarMenu.addItem( - withTitle: PikaText.textMenuGitHubIssue, - action: #selector(openGitHubIssue(_:)), - keyEquivalent: "" - ) - - let preferences = NSMenuItem( - title: "\(PikaText.textMenuPreferences)...", - action: #selector(openPreferencesWindow(_:)), - keyEquivalent: "," - ) - preferences.keyEquivalentModifierMask = NSEvent.ModifierFlags.command - statusBarMenu.addItem(preferences) - - statusBarMenu.addItem(NSMenuItem.separator()) - statusBarMenu.addItem( - withTitle: PikaText.textMenuQuit, - action: #selector(terminatePika(_:)), - keyEquivalent: "" - ) - - return statusBarMenu - } - - @objc func statusBarClicked(sender _: NSStatusBarButton) { - let event = NSApp.currentEvent - if event != nil, event!.type == NSEvent.EventType.rightMouseUp || event!.modifierFlags.contains(.control) { - statusBarItem.menu = statusBarMenu - statusBarItem.button?.performClick(nil) - } else { - togglePopover(nil) - } - } - - @objc func menuDidClose(_: NSMenu) { - statusBarItem.menu = nil - } - - @objc func togglePopover(_: AnyObject?) { - if pikaWindow.isVisible { - hideMainWindow() - } else { - showMainWindow() - NSApp.activate(ignoringOtherApps: true) - } - } - - @IBAction func openAboutWindow(_: Any?) { - if aboutWindow == nil { - let view = NSHostingView(rootView: AboutView().edgesIgnoringSafeArea(.all)) - aboutWindow = PikaWindow.createSecondaryWindow( - title: "About", - size: NSRect(x: 0, y: 0, width: 750, height: 650), - styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView] - ) - aboutWindow.contentView = view - } - aboutTouchBarController = SplashTouchBarController(window: aboutWindow) - aboutWindow.makeKeyAndOrderFront(nil) - } - - @IBAction func openPreferencesWindow(_: Any?) { - if preferencesWindow == nil { - let view = NSHostingView(rootView: PreferencesView() - .edgesIgnoringSafeArea(.all) - .frame(minWidth: 750, - maxWidth: .infinity, - minHeight: 0, - maxHeight: 750, - alignment: .topLeading) - .fixedSize(horizontal: false, vertical: true) - .environmentObject(eyedroppers)) - - preferencesWindow = PikaWindow.createSecondaryWindow( - title: "Preferences", - size: NSRect(x: 0, y: 0, width: 750, height: 750), - styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], - maxHeight: 750, - ) - preferencesWindow.contentView = view - } - preferencesWindow.makeKeyAndOrderFront(nil) - preferencesWindow.makeFirstResponder(nil) - notificationCenter.post(name: .triggerPreferences, object: self) - } - - @IBAction func openSplashWindow(_: Any?) { - splashWindow = PikaWindow.createSecondaryWindow( - title: "Splash", - size: NSRect(x: 0, y: 0, width: 650, height: 380), - styleMask: [.titled, .fullSizeContentView] - ) - splashWindow.title = PikaText.textAppName - splashWindow.titleVisibility = .visible - splashTouchBarController = SplashTouchBarController(window: splashWindow) - splashWindow.contentView = NSHostingView(rootView: SplashView().edgesIgnoringSafeArea(.all)) - - splashWindow.fadeIn(nil) - } + // MARK: - Notification dispatch @IBAction func triggerPickForeground(_: Any) { notificationCenter.post(name: .triggerPickForeground, object: self) @@ -442,18 +194,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { notificationCenter.post(name: .triggerFormatOKLCH, object: self) } - @IBAction func hidePika(_: Any) { - hideMainWindow() - } - - @IBAction func showPika(_: Any) { - if pikaWindow.isVisible { - pikaWindow.makeKeyAndOrderFront(self) - } else { - pikaWindow.fadeIn(sender: nil, duration: 0.2) - } - NSApp.activate(ignoringOtherApps: true) - } + // MARK: - App actions #if TARGET_SPARKLE @IBAction func updateFeedURL(_: Any) { @@ -482,6 +223,3 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { NSApplication.shared.terminate(self) } } - -// swiftlint:enable type_body_length -// swiftlint:enable file_length diff --git a/Pika/Styles/AppearanceButtonStyle.swift b/Pika/ButtonStyles/AppearanceButtonStyle.swift similarity index 100% rename from Pika/Styles/AppearanceButtonStyle.swift rename to Pika/ButtonStyles/AppearanceButtonStyle.swift diff --git a/Pika/Styles/CircleButtonStyle.swift b/Pika/ButtonStyles/CircleButtonStyle.swift similarity index 100% rename from Pika/Styles/CircleButtonStyle.swift rename to Pika/ButtonStyles/CircleButtonStyle.swift diff --git a/Pika/Styles/EyedropperButtonStyle.swift b/Pika/ButtonStyles/EyedropperButtonStyle.swift similarity index 100% rename from Pika/Styles/EyedropperButtonStyle.swift rename to Pika/ButtonStyles/EyedropperButtonStyle.swift diff --git a/Pika/Styles/SwapButtonStyle.swift b/Pika/ButtonStyles/SwapButtonStyle.swift similarity index 100% rename from Pika/Styles/SwapButtonStyle.swift rename to Pika/ButtonStyles/SwapButtonStyle.swift diff --git a/Pika/Utilities/ClosestVector.swift b/Pika/Services/ClosestVector.swift similarity index 100% rename from Pika/Utilities/ClosestVector.swift rename to Pika/Services/ClosestVector.swift diff --git a/Pika/Utilities/ColorPickOverlayWindow.swift b/Pika/Services/ColorPickOverlayWindow.swift similarity index 100% rename from Pika/Utilities/ColorPickOverlayWindow.swift rename to Pika/Services/ColorPickOverlayWindow.swift diff --git a/Pika/Utilities/Exporter.swift b/Pika/Services/Exporter.swift similarity index 100% rename from Pika/Utilities/Exporter.swift rename to Pika/Services/Exporter.swift diff --git a/Pika/Utilities/Eyedroppers.swift b/Pika/Services/Eyedroppers.swift similarity index 100% rename from Pika/Utilities/Eyedroppers.swift rename to Pika/Services/Eyedroppers.swift diff --git a/Pika/Utilities/LoadColors.swift b/Pika/Services/LoadColors.swift similarity index 100% rename from Pika/Utilities/LoadColors.swift rename to Pika/Services/LoadColors.swift diff --git a/Pika/Utilities/OverflowContentViewModifier.swift b/Pika/Services/OverflowContentViewModifier.swift similarity index 100% rename from Pika/Utilities/OverflowContentViewModifier.swift rename to Pika/Services/OverflowContentViewModifier.swift diff --git a/Pika/Utilities/PikaWindow.swift b/Pika/Services/PikaWindow.swift similarity index 100% rename from Pika/Utilities/PikaWindow.swift rename to Pika/Services/PikaWindow.swift diff --git a/Pika/Services/StatusBarController.swift b/Pika/Services/StatusBarController.swift new file mode 100644 index 00000000..1e1c3834 --- /dev/null +++ b/Pika/Services/StatusBarController.swift @@ -0,0 +1,88 @@ +import Cocoa +import Defaults + +/// Owns the status bar item — setup, visibility, and click handling. +/// Calls `onToggle` when the user left-clicks the icon. +class StatusBarController: NSObject, NSMenuDelegate { + private var statusBarItem: NSStatusItem! + private var statusBarMenu: NSMenu! + + /// Called when the user left-clicks the status bar icon. + var onToggle: (() -> Void)? + + func setup() { + let statusBar = NSStatusBar.system + statusBarItem = statusBar.statusItem(withLength: CGFloat(NSStatusItem.variableLength)) + + if let button = statusBarItem.button { + button.image = NSImage(named: "StatusBarIcon") + button.target = self + button.action = #selector(statusBarClicked(sender:)) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + } + + statusBarMenu = buildMenu() + + statusBarItem.isVisible = Defaults[.hideMenuBarIcon] == false && Defaults[.appMode] == .menubar + + Defaults.observe(.hideMenuBarIcon) { [weak self] change in + self?.statusBarItem.isVisible = change.newValue == false && Defaults[.appMode] == .menubar + }.tieToLifetime(of: self) + + Defaults.observe(.appMode) { [weak self] change in + self?.statusBarItem.isVisible = Defaults[.hideMenuBarIcon] == false && change.newValue == .menubar + }.tieToLifetime(of: self) + } + + private func buildMenu() -> NSMenu { + let menu = NSMenu(title: "Status Bar Menu") + menu.delegate = self + + menu.addItem( + withTitle: PikaText.textMenuAbout, + action: #selector(AppDelegate.openAboutWindow), + keyEquivalent: "" + ) + menu.addItem( + withTitle: "\(PikaText.textMenuUpdates)...", + action: #selector(AppDelegate.checkForUpdates), + keyEquivalent: "" + ) + menu.addItem( + withTitle: PikaText.textMenuGitHubIssue, + action: #selector(AppDelegate.openGitHubIssue), + keyEquivalent: "" + ) + + let preferences = NSMenuItem( + title: "\(PikaText.textMenuPreferences)...", + action: #selector(AppDelegate.openPreferencesWindow), + keyEquivalent: "," + ) + preferences.keyEquivalentModifierMask = NSEvent.ModifierFlags.command + menu.addItem(preferences) + + menu.addItem(NSMenuItem.separator()) + menu.addItem( + withTitle: PikaText.textMenuQuit, + action: #selector(AppDelegate.terminatePika), + keyEquivalent: "" + ) + + return menu + } + + @objc func statusBarClicked(sender _: NSStatusBarButton) { + let event = NSApp.currentEvent + if event != nil, event!.type == NSEvent.EventType.rightMouseUp || event!.modifierFlags.contains(.control) { + statusBarItem.menu = statusBarMenu + statusBarItem.button?.performClick(nil) + } else { + onToggle?() + } + } + + func menuDidClose(_: NSMenu) { + statusBarItem.menu = nil + } +} diff --git a/Pika/Services/URLSchemeHandler.swift b/Pika/Services/URLSchemeHandler.swift new file mode 100644 index 00000000..82d19437 --- /dev/null +++ b/Pika/Services/URLSchemeHandler.swift @@ -0,0 +1,62 @@ +import Cocoa +import Defaults + +/// Handles `pika://` URL scheme events and dispatches the corresponding app actions. +final class URLSchemeHandler: NSObject { + static let shared = URLSchemeHandler() + + @objc func handle(event: NSAppleEventDescriptor, withReplyEvent _: NSAppleEventDescriptor) { + guard + let urlString = event.forKeyword(AEKeyword(keyDirectObject))?.stringValue, + let url = URL(string: urlString), + let scheme = url.scheme, + let action = url.host, + scheme.caseInsensitiveCompare("pika") == .orderedSame + else { return } + + var list = url.pathComponents.dropFirst() + let task = list.popFirst() + let colorFormat = list.popFirst() + + if let colorFormat, let format = ColorFormat.withLabel(colorFormat) { + Defaults[.colorFormat] = format + } + + switch action { + case "format": + if let task, let format = ColorFormat.withLabel(task) { + Defaults[.colorFormat] = format + } + case "pick": + if task == "foreground" { + NSApp.sendAction(#selector(AppDelegate.triggerPickForeground), to: nil, from: nil) + } else if task == "background" { + NSApp.sendAction(#selector(AppDelegate.triggerPickBackground), to: nil, from: nil) + } + case "system": + if task == "foreground" { + NSApp.sendAction(#selector(AppDelegate.triggerSystemPickerForeground), to: nil, from: nil) + } else if task == "background" { + NSApp.sendAction(#selector(AppDelegate.triggerSystemPickerBackground), to: nil, from: nil) + } + case "copy": + if task == "foreground" { + NSApp.sendAction(#selector(AppDelegate.triggerCopyForeground), to: nil, from: nil) + } else if task == "background" { + NSApp.sendAction(#selector(AppDelegate.triggerCopyBackground), to: nil, from: nil) + } else if task == "text" { + NSApp.sendAction(#selector(AppDelegate.triggerCopyText), to: nil, from: nil) + } else if task == "json" { + NSApp.sendAction(#selector(AppDelegate.triggerCopyData), to: nil, from: nil) + } + case "swap": + NSApp.sendAction(#selector(AppDelegate.triggerSwap), to: nil, from: nil) + case "undo": + NSApp.sendAction(#selector(AppDelegate.triggerUndo), to: nil, from: nil) + case "redo": + NSApp.sendAction(#selector(AppDelegate.triggerRedo), to: nil, from: nil) + default: + break + } + } +} diff --git a/Pika/Services/WindowCoordinator.swift b/Pika/Services/WindowCoordinator.swift new file mode 100644 index 00000000..f6a4f9cc --- /dev/null +++ b/Pika/Services/WindowCoordinator.swift @@ -0,0 +1,131 @@ +import Cocoa +import Defaults +import SwiftUI + +/// Owns all secondary window lifecycle — creation, presentation, and visibility. +/// The primary Pika window is created here and exposed for `applicationShouldHandleReopen`. +class WindowCoordinator: NSObject { + weak var eyedroppers: Eyedroppers? + + private(set) var pikaWindow: NSWindow! + private var splashWindow: NSWindow! + private var aboutWindow: NSWindow? + private var preferencesWindow: NSWindow? + + private var pikaTouchBarController: PikaTouchBarController! + private var splashTouchBarController: SplashTouchBarController? + private var aboutTouchBarController: SplashTouchBarController? + + private let notificationCenter = NotificationCenter.default + + func setupMainWindow(eyedroppers: Eyedroppers) { + self.eyedroppers = eyedroppers + + let contentView = ContentView() + .environmentObject(eyedroppers) + .frame(minWidth: 480, + idealWidth: 480, + maxWidth: 650, + minHeight: 230, + idealHeight: 230, + maxHeight: 400, + alignment: .center) + + pikaWindow = PikaWindow.createPrimaryWindow() + pikaWindow.contentView = NSHostingView(rootView: contentView) + pikaTouchBarController = PikaTouchBarController(window: pikaWindow) + } + + func startMainWindow() { + if !pikaWindow.isVisible { + pikaWindow.fadeIn(nil) + } + Defaults[.viewedSplash] = true + } + + func showMainWindow() { + pikaWindow.makeKeyAndOrderFront(nil) + } + + func hideMainWindow() { + pikaWindow.orderOut(nil) + } + + func closeSplashWindow() { + splashWindow.fadeOut(sender: nil, duration: 0.25, closeSelector: .close, completionHandler: startMainWindow) + } + + func togglePopover() { + if pikaWindow.isVisible { + hideMainWindow() + } else { + showMainWindow() + NSApp.activate(ignoringOtherApps: true) + } + } + + func showPika() { + if pikaWindow.isVisible { + pikaWindow.makeKeyAndOrderFront(nil) + } else { + pikaWindow.fadeIn(sender: nil, duration: 0.2) + } + NSApp.activate(ignoringOtherApps: true) + } + + func hidePika() { + hideMainWindow() + } + + func openAboutWindow() { + if aboutWindow == nil { + let view = NSHostingView(rootView: AboutView().edgesIgnoringSafeArea(.all)) + aboutWindow = PikaWindow.createSecondaryWindow( + title: "About", + size: NSRect(x: 0, y: 0, width: 750, height: 650), + styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView] + ) + aboutWindow?.contentView = view + } + aboutTouchBarController = SplashTouchBarController(window: aboutWindow!) + aboutWindow?.makeKeyAndOrderFront(nil) + } + + func openPreferencesWindow() { + if preferencesWindow == nil, let eyedroppers { + let view = NSHostingView(rootView: PreferencesView() + .edgesIgnoringSafeArea(.all) + .frame(minWidth: 750, + maxWidth: .infinity, + minHeight: 0, + maxHeight: 750, + alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + .environmentObject(eyedroppers)) + + preferencesWindow = PikaWindow.createSecondaryWindow( + title: "Preferences", + size: NSRect(x: 0, y: 0, width: 750, height: 750), + styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], + maxHeight: 750, + ) + preferencesWindow?.contentView = view + } + preferencesWindow?.makeKeyAndOrderFront(nil) + preferencesWindow?.makeFirstResponder(nil) + notificationCenter.post(name: .triggerPreferences, object: self) + } + + func openSplashWindow() { + splashWindow = PikaWindow.createSecondaryWindow( + title: "Splash", + size: NSRect(x: 0, y: 0, width: 650, height: 380), + styleMask: [.titled, .fullSizeContentView] + ) + splashWindow.title = PikaText.textAppName + splashWindow.titleVisibility = .visible + splashTouchBarController = SplashTouchBarController(window: splashWindow) + splashWindow.contentView = NSHostingView(rootView: SplashView().edgesIgnoringSafeArea(.all)) + splashWindow.fadeIn(nil) + } +} diff --git a/Pika/Views/PreferencesView.swift b/Pika/Views/PreferencesView.swift index 8e5de079..905ff1f8 100644 --- a/Pika/Views/PreferencesView.swift +++ b/Pika/Views/PreferencesView.swift @@ -187,8 +187,13 @@ private struct CopySettingsSection: View { private struct ColorFormatSection: View { @State var colorSpace: NSColorSpace = Defaults[.colorSpace] - // swiftlint:disable large_tuple opening_brace - func getColorSpaces() -> ([NSColorSpace], [NSColorSpace], NSColorSpace) { + private struct ColorSpaceConfig { + let systemDefault: NSColorSpace + let primary: [NSColorSpace] + let additional: [NSColorSpace] + } + + private func getColorSpaces() -> ColorSpaceConfig { let systemDefaultSpace: NSColorSpace = NSScreen.main!.colorSpace! var availableSpaces = NSColorSpace.availableColorSpaces(with: .rgb).unique() if !availableSpaces.contains(systemDefaultSpace) { @@ -208,13 +213,14 @@ private struct ColorFormatSection: View { } } } - return (primarySpaces, availableSpaces, systemDefaultSpace) + return ColorSpaceConfig(systemDefault: systemDefaultSpace, primary: primarySpaces, additional: availableSpaces) } - // swiftlint:enable large_tuple opening_brace - var body: some View { - let (primarySpaces, availableSpaces, systemDefaultSpace) = getColorSpaces() + let spaces = getColorSpaces() + let primarySpaces = spaces.primary + let availableSpaces = spaces.additional + let systemDefaultSpace = spaces.systemDefault VStack(alignment: .leading, spacing: 8.0) { Section(header: Text(PikaText.textFormatTitle).font(.system(size: 16))) { diff --git a/PikaTests/APCAComplianceTests.swift b/PikaTests/APCAComplianceTests.swift index 8ef7984a..c362a21a 100644 --- a/PikaTests/APCAComplianceTests.swift +++ b/PikaTests/APCAComplianceTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import Pika +import XCTest final class APCAComplianceTests: XCTestCase { // MARK: - APCACompliance(with:) — level strings diff --git a/PikaTests/NSColorHSLTests.swift b/PikaTests/NSColorHSLTests.swift index 0d9d80a0..9ccc96f9 100644 --- a/PikaTests/NSColorHSLTests.swift +++ b/PikaTests/NSColorHSLTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import Pika +import XCTest final class NSColorHSLTests: XCTestCase { // MARK: - toHSBComponents() @@ -58,9 +58,9 @@ final class NSColorHSLTests: XCTestCase { func test_toHSLComponents_red_correctValues() { let red = NSColor(hex: "FF0000") let hsl = red.toHSLComponents() - XCTAssertEqual(hsl.h, 0.0, accuracy: 0.01) // hue = 0° - XCTAssertEqual(hsl.s, 1.0, accuracy: 0.01) // saturation = 100% - XCTAssertEqual(hsl.l, 0.5, accuracy: 0.01) // lightness = 50% + XCTAssertEqual(hsl.h, 0.0, accuracy: 0.01) // hue = 0° + XCTAssertEqual(hsl.s, 1.0, accuracy: 0.01) // saturation = 100% + XCTAssertEqual(hsl.l, 0.5, accuracy: 0.01) // lightness = 50% } func test_toHSLComponents_black_allZero() { diff --git a/PikaTests/NSColorHexTests.swift b/PikaTests/NSColorHexTests.swift index d0e5123e..caf59406 100644 --- a/PikaTests/NSColorHexTests.swift +++ b/PikaTests/NSColorHexTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import Pika +import XCTest final class NSColorHexTests: XCTestCase { // MARK: - roundToHex() diff --git a/PikaTests/NSColorInitTests.swift b/PikaTests/NSColorInitTests.swift index 2cac265b..7c3fcb74 100644 --- a/PikaTests/NSColorInitTests.swift +++ b/PikaTests/NSColorInitTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import Pika +import XCTest final class NSColorInitTests: XCTestCase { // MARK: - init(r:g:b:a:) — 255-based values diff --git a/PikaTests/NSColorLabTests.swift b/PikaTests/NSColorLabTests.swift index 9c6cbb08..da542dac 100644 --- a/PikaTests/NSColorLabTests.swift +++ b/PikaTests/NSColorLabTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import Pika +import XCTest final class NSColorLabTests: XCTestCase { // MARK: - toOpenGLString(style:) diff --git a/PikaTests/NSColorLuminanceTests.swift b/PikaTests/NSColorLuminanceTests.swift index e75929c3..ea0eab85 100644 --- a/PikaTests/NSColorLuminanceTests.swift +++ b/PikaTests/NSColorLuminanceTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import Pika +import XCTest final class NSColorLuminanceTests: XCTestCase { // MARK: - clip() diff --git a/PikaTests/NSColorRGBTests.swift b/PikaTests/NSColorRGBTests.swift index 17330666..8bfead9a 100644 --- a/PikaTests/NSColorRGBTests.swift +++ b/PikaTests/NSColorRGBTests.swift @@ -1,6 +1,6 @@ +@testable import Pika import SwiftUI import XCTest -@testable import Pika final class NSColorRGBTests: XCTestCase { // MARK: - toRGBAComponents(in:) diff --git a/PikaTests/NotificationNamesTests.swift b/PikaTests/NotificationNamesTests.swift index ff7f9a97..6954d2b5 100644 --- a/PikaTests/NotificationNamesTests.swift +++ b/PikaTests/NotificationNamesTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import Pika +import XCTest /// Verifies that every typed Notification.Name constant resolves to its /// expected raw string value. These tests catch accidental regressions diff --git a/PikaTests/WCAGComplianceTests.swift b/PikaTests/WCAGComplianceTests.swift index e3d3ee18..bac2f432 100644 --- a/PikaTests/WCAGComplianceTests.swift +++ b/PikaTests/WCAGComplianceTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import Pika +import XCTest final class WCAGComplianceTests: XCTestCase { // White on black — contrast 21:1 — all levels pass From ff2e19b1f0fd39fbcd469f920a565f37a2cb65c8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:01:41 -0700 Subject: [PATCH 11/18] Initial plan (#185) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> From a3b1a8c01b94f0c7210a7d553ff95c5fd513880a Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 18:01:59 -0700 Subject: [PATCH 12/18] Fix menu width --- Pika/Views/NavigationMenu.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Pika/Views/NavigationMenu.swift b/Pika/Views/NavigationMenu.swift index dd807d00..b120f948 100644 --- a/Pika/Views/NavigationMenu.swift +++ b/Pika/Views/NavigationMenu.swift @@ -23,7 +23,6 @@ struct NavigationMenu: View { .pickerStyle(.menu) .labelsHidden() .fixedSize() - .frame(minWidth: 90) Menu { NavigationMenuItems() From 5741e41a3d5469f3094c5ac353f0cc7f93a08fb2 Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 19:31:55 -0700 Subject: [PATCH 13/18] Fixed spacing and menu --- Pika/Views/NavigationMenu.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Pika/Views/NavigationMenu.swift b/Pika/Views/NavigationMenu.swift index b120f948..f880f56c 100644 --- a/Pika/Views/NavigationMenu.swift +++ b/Pika/Views/NavigationMenu.swift @@ -13,7 +13,7 @@ struct NavigationMenu: View { } var body: some View { - HStack(spacing: 0) { + HStack(spacing: 4.0) { Picker(PikaText.textFormatTitle, selection: $colorFormat) { ForEach(ColorFormat.allCases, id: \.self) { value in Text(value.rawValue) @@ -23,6 +23,7 @@ struct NavigationMenu: View { .pickerStyle(.menu) .labelsHidden() .fixedSize() + .frame(width: 100.0) Menu { NavigationMenuItems() From 8c68189f579ecee43bdf072feece9a9664cf23fc Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 20:16:49 -0700 Subject: [PATCH 14/18] Fix button flicker, SwiftLint violations, and color space test sensitivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SwapButtonStyle: remove internal onHover (child hover was breaking parent hover state, causing flicker); alt text now shows when button is visible - EyedropperButton, ContentView: add nil-guard on hide task creation to prevent rapid task churn when hover events fire at boundaries (mirrors the original Timer.publish guard behaviour) - WCAGComplianceTests: rename single-char vars (a→colorA etc.) and remove trailing comma to satisfy SwiftLint identifier_name/trailing_comma rules - NSColorHexTests, NSColorRGBTests, NSColorHSLTests: pin Defaults[.colorSpace] to sRGB in setUp so string-output tests pass regardless of display profile; fix two roundtrip tests to convert calibrated-RGB input to sRGB explicitly Co-Authored-By: Claude Sonnet 4.6 --- Pika/ButtonStyles/SwapButtonStyle.swift | 20 ++------------------ Pika/Views/ContentView.swift | 3 ++- Pika/Views/EyedropperButton.swift | 3 ++- PikaTests/NSColorHSLTests.swift | 5 +++++ PikaTests/NSColorHexTests.swift | 10 ++++++++-- PikaTests/NSColorRGBTests.swift | 5 +++++ PikaTests/WCAGComplianceTests.swift | 16 ++++++++-------- 7 files changed, 32 insertions(+), 30 deletions(-) diff --git a/Pika/ButtonStyles/SwapButtonStyle.swift b/Pika/ButtonStyles/SwapButtonStyle.swift index 0b74b2c9..5acbaeb3 100644 --- a/Pika/ButtonStyles/SwapButtonStyle.swift +++ b/Pika/ButtonStyles/SwapButtonStyle.swift @@ -8,9 +8,6 @@ struct SwapButtonStyle: ButtonStyle { private struct SwapButtonStyleView: View { @Environment(\.colorScheme) var colorScheme: ColorScheme - @State private var isHovered: Bool = false - @State private var hoverTask: Task? - let configuration: Configuration let isVisible: Bool let alt: String @@ -23,13 +20,13 @@ struct SwapButtonStyle: ButtonStyle { HStack { if ltr { configuration.label - if isHovered { + if isVisible { Text(alt) .font(.system(size: 12.0)) .padding(.trailing, 2) } } else { - if isHovered { + if isVisible { Text(alt) .font(.system(size: 12.0)) .padding(.leading, 6) @@ -56,19 +53,6 @@ struct SwapButtonStyle: ButtonStyle { ) } ) - .onHover { hover in - if hover { - hoverTask?.cancel() - hoverTask = Task { - try? await Task.sleep(for: .milliseconds(100)) - isHovered = true - } - } else { - hoverTask?.cancel() - hoverTask = nil - isHovered = false - } - } .opacity(isVisible ? (configuration.isPressed ? 0.8 : 1.0) : 0.0) .foregroundColor(fgColor.opacity(0.8)) .frame(height: 32.0) diff --git a/Pika/Views/ContentView.swift b/Pika/Views/ContentView.swift index 47e33d7b..bec97676 100644 --- a/Pika/Views/ContentView.swift +++ b/Pika/Views/ContentView.swift @@ -22,10 +22,11 @@ struct ContentView: View { swapHideTask?.cancel() swapHideTask = nil swapVisible = true - } else { + } else if swapHideTask == nil { swapHideTask = Task { try? await Task.sleep(for: .milliseconds(250)) swapVisible = false + swapHideTask = nil } } } diff --git a/Pika/Views/EyedropperButton.swift b/Pika/Views/EyedropperButton.swift index 735979e0..b1ac134a 100644 --- a/Pika/Views/EyedropperButton.swift +++ b/Pika/Views/EyedropperButton.swift @@ -85,10 +85,11 @@ struct EyedropperButton: View { hoverTask?.cancel() hoverTask = nil hoverVisible = true - } else { + } else if hoverTask == nil { hoverTask = Task { try? await Task.sleep(for: .milliseconds(250)) hoverVisible = false + hoverTask = nil } } } diff --git a/PikaTests/NSColorHSLTests.swift b/PikaTests/NSColorHSLTests.swift index 9ccc96f9..1e8800ab 100644 --- a/PikaTests/NSColorHSLTests.swift +++ b/PikaTests/NSColorHSLTests.swift @@ -1,7 +1,12 @@ @testable import Pika +import Defaults import XCTest final class NSColorHSLTests: XCTestCase { + override func setUp() { + super.setUp() + Defaults[.colorSpace] = .sRGB + } // MARK: - toHSBComponents() func test_toHSBComponents_red_hueIsZero() { diff --git a/PikaTests/NSColorHexTests.swift b/PikaTests/NSColorHexTests.swift index caf59406..fac667b8 100644 --- a/PikaTests/NSColorHexTests.swift +++ b/PikaTests/NSColorHexTests.swift @@ -1,7 +1,13 @@ @testable import Pika +import Defaults import XCTest final class NSColorHexTests: XCTestCase { + override func setUp() { + super.setUp() + Defaults[.colorSpace] = .sRGB + } + // MARK: - roundToHex() func test_roundToHex_zero_returnsZero() { @@ -81,13 +87,13 @@ final class NSColorHexTests: XCTestCase { } func test_toHexString_isLowercase() { - let color = NSColor(hex: "AABBCC") + let color = NSColor(hex: "AABBCC").usingColorSpace(.sRGB)! XCTAssertEqual(color.toHexString(style: .css), "#aabbcc") } func test_initHex_roundtrips_throughToHexString() { let original = "#3a7bd5" - let color = NSColor(hex: original) + let color = NSColor(hex: original).usingColorSpace(.sRGB)! XCTAssertEqual(color.toHexString(style: .css), original) } } diff --git a/PikaTests/NSColorRGBTests.swift b/PikaTests/NSColorRGBTests.swift index 8bfead9a..cc665839 100644 --- a/PikaTests/NSColorRGBTests.swift +++ b/PikaTests/NSColorRGBTests.swift @@ -1,8 +1,13 @@ @testable import Pika +import Defaults import SwiftUI import XCTest final class NSColorRGBTests: XCTestCase { + override func setUp() { + super.setUp() + Defaults[.colorSpace] = .sRGB + } // MARK: - toRGBAComponents(in:) func test_toRGBAComponents_red() { diff --git a/PikaTests/WCAGComplianceTests.swift b/PikaTests/WCAGComplianceTests.swift index bac2f432..0f518b31 100644 --- a/PikaTests/WCAGComplianceTests.swift +++ b/PikaTests/WCAGComplianceTests.swift @@ -56,13 +56,13 @@ final class WCAGComplianceTests: XCTestCase { // isSymmetric — contrast is the same regardless of order func test_WCAGCompliance_isSymmetric() { - let a = NSColor(r: 50, g: 100, b: 200) - let b = NSColor(r: 220, g: 220, b: 220) - let ab = a.WCAGCompliance(with: b) - let ba = b.WCAGCompliance(with: a) - XCTAssertEqual(ab.ratio30, ba.ratio30) - XCTAssertEqual(ab.ratio45, ba.ratio45) - XCTAssertEqual(ab.ratio70, ba.ratio70) + let colorA = NSColor(r: 50, g: 100, b: 200) + let colorB = NSColor(r: 220, g: 220, b: 220) + let colorAB = colorA.WCAGCompliance(with: colorB) + let colorBA = colorB.WCAGCompliance(with: colorA) + XCTAssertEqual(colorAB.ratio30, colorBA.ratio30) + XCTAssertEqual(colorAB.ratio45, colorBA.ratio45) + XCTAssertEqual(colorAB.ratio70, colorBA.ratio70) } // Ratio levels are cumulative — ratio70 can't pass without ratio45 @@ -72,7 +72,7 @@ final class WCAGComplianceTests: XCTestCase { NSColor(r: 50, g: 50, b: 50), NSColor(r: 128, g: 128, b: 128), NSColor(r: 200, g: 200, b: 200), - NSColor(r: 255, g: 255, b: 255), + NSColor(r: 255, g: 255, b: 255) ] let white = NSColor(r: 255, g: 255, b: 255) for color in colors { From 842fb03a0d8ebd8b74d971e342aad9e392a29487 Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 20:22:20 -0700 Subject: [PATCH 15/18] Fix remaining SwiftLint violations, HSB hue edge case, and trailing comma conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NSColor+HSL: normalize HSB hue with truncatingRemainder so pure red returns 0° (h=0.0) instead of 360° (h=1.0) — fixes user-visible bug where the app displayed "hsb(360, 100%, 100%)" for red - APCAComplianceTests: rename fg→foreground, bg→background, a→colorA, b→colorB to satisfy identifier_name; remove trailing comma from pairs array - .swiftlint.yml: disable trailing_comma — Xcode's formatter adds trailing commas automatically (Swift convention), creating an unresolvable conflict; disabling the rule aligns linting with the project's actual formatting practice - Import order + blank-line fixes applied by Xcode formatter accepted as-is Co-Authored-By: Claude Sonnet 4.6 --- .swiftlint.yml | 1 + Pika/Extensions/NSColor+HSL.swift | 1 + PikaTests/APCAComplianceTests.swift | 14 +++++++------- PikaTests/NSColorHSLTests.swift | 3 ++- PikaTests/NSColorHexTests.swift | 2 +- PikaTests/NSColorRGBTests.swift | 3 ++- PikaTests/WCAGComplianceTests.swift | 2 +- 7 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 910d3c82..c5641597 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,2 +1,3 @@ disabled_rules: # rule identifiers turned on by default to exclude from running - opening_brace + - trailing_comma diff --git a/Pika/Extensions/NSColor+HSL.swift b/Pika/Extensions/NSColor+HSL.swift index 1c0699a6..2ab798c0 100644 --- a/Pika/Extensions/NSColor+HSL.swift +++ b/Pika/Extensions/NSColor+HSL.swift @@ -24,6 +24,7 @@ extension NSColor { } rgbaColor.getHue(&h, saturation: &s, brightness: &b, alpha: nil) + h = h.truncatingRemainder(dividingBy: 1.0) return (h: h, s: s, b: b) } diff --git a/PikaTests/APCAComplianceTests.swift b/PikaTests/APCAComplianceTests.swift index c362a21a..d5a5a45d 100644 --- a/PikaTests/APCAComplianceTests.swift +++ b/PikaTests/APCAComplianceTests.swift @@ -30,10 +30,10 @@ final class APCAComplianceTests: XCTestCase { let pairs: [(NSColor, NSColor)] = [ (NSColor(r: 0, g: 0, b: 0), NSColor(r: 0, g: 0, b: 0)), (NSColor(r: 50, g: 50, b: 50), NSColor(r: 240, g: 240, b: 240)), - (NSColor(r: 0, g: 0, b: 0), NSColor(r: 255, g: 255, b: 255)), + (NSColor(r: 0, g: 0, b: 0), NSColor(r: 255, g: 255, b: 255)) ] - for (fg, bg) in pairs { - let level = fg.APCACompliance(with: bg).level + for (foreground, background) in pairs { + let level = foreground.APCACompliance(with: background).level XCTAssertTrue(knownLevels.contains(level), "Unexpected level: \(level)") } } @@ -69,10 +69,10 @@ final class APCAComplianceTests: XCTestCase { // MARK: - toAPCACompliance(with:) alias func test_toAPCACompliance_equivalentToAPCACompliance() { - let a = NSColor(r: 50, g: 100, b: 200) - let b = NSColor(r: 220, g: 220, b: 220) - let direct = a.APCACompliance(with: b) - let alias = a.toAPCACompliance(with: b) + let colorA = NSColor(r: 50, g: 100, b: 200) + let colorB = NSColor(r: 220, g: 220, b: 220) + let direct = colorA.APCACompliance(with: colorB) + let alias = colorA.toAPCACompliance(with: colorB) XCTAssertEqual(direct.level, alias.level) XCTAssertEqual(direct.value, alias.value, accuracy: 0.001) } diff --git a/PikaTests/NSColorHSLTests.swift b/PikaTests/NSColorHSLTests.swift index 1e8800ab..c444ecc4 100644 --- a/PikaTests/NSColorHSLTests.swift +++ b/PikaTests/NSColorHSLTests.swift @@ -1,5 +1,5 @@ -@testable import Pika import Defaults +@testable import Pika import XCTest final class NSColorHSLTests: XCTestCase { @@ -7,6 +7,7 @@ final class NSColorHSLTests: XCTestCase { super.setUp() Defaults[.colorSpace] = .sRGB } + // MARK: - toHSBComponents() func test_toHSBComponents_red_hueIsZero() { diff --git a/PikaTests/NSColorHexTests.swift b/PikaTests/NSColorHexTests.swift index fac667b8..e202f0dd 100644 --- a/PikaTests/NSColorHexTests.swift +++ b/PikaTests/NSColorHexTests.swift @@ -1,5 +1,5 @@ -@testable import Pika import Defaults +@testable import Pika import XCTest final class NSColorHexTests: XCTestCase { diff --git a/PikaTests/NSColorRGBTests.swift b/PikaTests/NSColorRGBTests.swift index cc665839..28bcb079 100644 --- a/PikaTests/NSColorRGBTests.swift +++ b/PikaTests/NSColorRGBTests.swift @@ -1,5 +1,5 @@ -@testable import Pika import Defaults +@testable import Pika import SwiftUI import XCTest @@ -8,6 +8,7 @@ final class NSColorRGBTests: XCTestCase { super.setUp() Defaults[.colorSpace] = .sRGB } + // MARK: - toRGBAComponents(in:) func test_toRGBAComponents_red() { diff --git a/PikaTests/WCAGComplianceTests.swift b/PikaTests/WCAGComplianceTests.swift index 0f518b31..4963e989 100644 --- a/PikaTests/WCAGComplianceTests.swift +++ b/PikaTests/WCAGComplianceTests.swift @@ -72,7 +72,7 @@ final class WCAGComplianceTests: XCTestCase { NSColor(r: 50, g: 50, b: 50), NSColor(r: 128, g: 128, b: 128), NSColor(r: 200, g: 200, b: 200), - NSColor(r: 255, g: 255, b: 255) + NSColor(r: 255, g: 255, b: 255), ] let white = NSColor(r: 255, g: 255, b: 255) for color in colors { From 855c81599538769328b8a37975ce87346a11131f Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 20:28:31 -0700 Subject: [PATCH 16/18] Restore hover-driven alt text without flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add onHoverChange callback to SwapButtonStyle so parent views know when a child button is hovered. SwiftUI fires inner .onHover before outer, so by the time the parent's false-handler runs, childHovered/swapButtonHovered is already true → the parent skips starting the 250ms hide task. - SwapButtonStyle: restore @State isHovered + 100ms task for alt text; add onHoverChange: ((Bool) -> Void)? parameter; guard hoverTask == nil (matches original Timer.publish behaviour) - EyedropperButton: add childHovered flag; wire onHoverChange on both overlay buttons; skip hide task when childHovered is true - ContentView: add swapButtonHovered flag; wire onHoverChange on swap button; skip hide task when swapButtonHovered is true Co-Authored-By: Claude Sonnet 4.6 --- Pika/ButtonStyles/SwapButtonStyle.swift | 33 ++++++++++++++++++++++--- Pika/Views/ContentView.swift | 9 +++++-- Pika/Views/EyedropperButton.swift | 15 ++++++++--- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/Pika/ButtonStyles/SwapButtonStyle.swift b/Pika/ButtonStyles/SwapButtonStyle.swift index 5acbaeb3..21ccfc27 100644 --- a/Pika/ButtonStyles/SwapButtonStyle.swift +++ b/Pika/ButtonStyles/SwapButtonStyle.swift @@ -4,14 +4,19 @@ struct SwapButtonStyle: ButtonStyle { let isVisible: Bool let alt: String var ltr = false + var onHoverChange: ((Bool) -> Void)? private struct SwapButtonStyleView: View { @Environment(\.colorScheme) var colorScheme: ColorScheme + @State private var isHovered: Bool = false + @State private var hoverTask: Task? + let configuration: Configuration let isVisible: Bool let alt: String let ltr: Bool + let onHoverChange: ((Bool) -> Void)? var body: some View { let fgColor = colorScheme == .dark ? Color.white : .black @@ -20,13 +25,13 @@ struct SwapButtonStyle: ButtonStyle { HStack { if ltr { configuration.label - if isVisible { + if isHovered { Text(alt) .font(.system(size: 12.0)) .padding(.trailing, 2) } } else { - if isVisible { + if isHovered { Text(alt) .font(.system(size: 12.0)) .padding(.leading, 6) @@ -53,6 +58,22 @@ struct SwapButtonStyle: ButtonStyle { ) } ) + .onHover { hover in + onHoverChange?(hover) + if hover { + if hoverTask == nil { + hoverTask = Task { + try? await Task.sleep(for: .milliseconds(100)) + isHovered = true + hoverTask = nil + } + } + } else { + hoverTask?.cancel() + hoverTask = nil + isHovered = false + } + } .opacity(isVisible ? (configuration.isPressed ? 0.8 : 1.0) : 0.0) .foregroundColor(fgColor.opacity(0.8)) .frame(height: 32.0) @@ -62,6 +83,12 @@ struct SwapButtonStyle: ButtonStyle { } func makeBody(configuration: Self.Configuration) -> some View { - SwapButtonStyleView(configuration: configuration, isVisible: isVisible, alt: alt, ltr: ltr) + SwapButtonStyleView( + configuration: configuration, + isVisible: isVisible, + alt: alt, + ltr: ltr, + onHoverChange: onHoverChange + ) } } diff --git a/Pika/Views/ContentView.swift b/Pika/Views/ContentView.swift index bec97676..34fe8b7b 100644 --- a/Pika/Views/ContentView.swift +++ b/Pika/Views/ContentView.swift @@ -11,6 +11,7 @@ struct ContentView: View { @State var swapVisible: Bool = false @State private var swapHideTask: Task? + @State private var swapButtonHovered: Bool = false @State private var angle: Double = 0 var body: some View { @@ -22,7 +23,7 @@ struct ContentView: View { swapHideTask?.cancel() swapHideTask = nil swapVisible = true - } else if swapHideTask == nil { + } else if swapHideTask == nil && !swapButtonHovered { swapHideTask = Task { try? await Task.sleep(for: .milliseconds(250)) swapVisible = false @@ -42,7 +43,11 @@ struct ContentView: View { .buttonStyle(SwapButtonStyle( isVisible: swapVisible, alt: PikaText.textColorSwap, - ltr: true + ltr: true, + onHoverChange: { hover in + swapButtonHovered = hover + if hover { swapHideTask?.cancel(); swapHideTask = nil } + } )) .onReceive(NotificationCenter.default.publisher(for: .triggerSwap)) { _ in swap(&eyedroppers.foreground.color, &eyedroppers.background.color) diff --git a/Pika/Views/EyedropperButton.swift b/Pika/Views/EyedropperButton.swift index b1ac134a..0e21a39a 100644 --- a/Pika/Views/EyedropperButton.swift +++ b/Pika/Views/EyedropperButton.swift @@ -9,6 +9,7 @@ struct EyedropperButton: View { @State var hoverVisible: Bool = false @State private var hoverTask: Task? + @State private var childHovered: Bool = false var body: some View { ZStack { @@ -58,7 +59,11 @@ struct EyedropperButton: View { }) .buttonStyle(SwapButtonStyle( isVisible: hoverVisible, - alt: PikaText.textColorCopy + alt: PikaText.textColorCopy, + onHoverChange: { hover in + childHovered = hover + if hover { hoverTask?.cancel(); hoverTask = nil } + } )) .frame(maxWidth: .infinity, alignment: .trailing) .focusable(false) @@ -72,7 +77,11 @@ struct EyedropperButton: View { }) .buttonStyle(SwapButtonStyle( isVisible: hoverVisible, - alt: PikaText.textColorSystemPicker + alt: PikaText.textColorSystemPicker, + onHoverChange: { hover in + childHovered = hover + if hover { hoverTask?.cancel(); hoverTask = nil } + } )) .frame(maxWidth: .infinity, alignment: .trailing) .focusable(false) @@ -85,7 +94,7 @@ struct EyedropperButton: View { hoverTask?.cancel() hoverTask = nil hoverVisible = true - } else if hoverTask == nil { + } else if hoverTask == nil && !childHovered { hoverTask = Task { try? await Task.sleep(for: .milliseconds(250)) hoverVisible = false From 0573e9ae07e641a6d61a020695c90bee1a056fe1 Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Mon, 9 Mar 2026 21:38:04 -0700 Subject: [PATCH 17/18] Fixed it --- Pika/ButtonStyles/SwapButtonStyle.swift | 77 +++++++++++++------------ Pika/Views/ContentView.swift | 38 ++++++------ Pika/Views/EyedropperButton.swift | 2 +- PikaTests/APCAComplianceTests.swift | 2 +- 4 files changed, 61 insertions(+), 58 deletions(-) diff --git a/Pika/ButtonStyles/SwapButtonStyle.swift b/Pika/ButtonStyles/SwapButtonStyle.swift index 21ccfc27..4a5e9170 100644 --- a/Pika/ButtonStyles/SwapButtonStyle.swift +++ b/Pika/ButtonStyles/SwapButtonStyle.swift @@ -38,47 +38,48 @@ struct SwapButtonStyle: ButtonStyle { } configuration.label } - } - .padding(.horizontal, 8) - .padding(.vertical, 8) - .mask(RoundedRectangle(cornerRadius: 100.0, style: .continuous)) - .background( - ZStack { - RoundedRectangle(cornerRadius: 100.0, style: .continuous) - .fill(bgColor) - .shadow( - color: Color.black.opacity(0.2), - radius: configuration.isPressed ? 1 : 2, - x: 0, - y: configuration.isPressed ? 1 : 2 - ) - .overlay( - RoundedRectangle(cornerRadius: 100.0, style: .continuous) - .stroke(fgColor.opacity(0.1)) - ) - } - ) - .onHover { hover in - onHoverChange?(hover) - if hover { - if hoverTask == nil { - hoverTask = Task { - try? await Task.sleep(for: .milliseconds(100)) - isHovered = true - hoverTask = nil + }.animation(.easeInOut, value: isHovered) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .mask(RoundedRectangle(cornerRadius: 100.0, style: .continuous)) + .background( + ZStack { + RoundedRectangle(cornerRadius: 100.0, style: .continuous) + .fill(bgColor) + .shadow( + color: Color.black.opacity(0.2), + radius: configuration.isPressed ? 1 : 2, + x: 0, + y: configuration.isPressed ? 1 : 2 + ) + .overlay( + RoundedRectangle(cornerRadius: 100.0, style: .continuous) + .stroke(fgColor.opacity(0.1)) + ) + } + ) + .onHover { hover in + onHoverChange?(hover) + if hover { + if hoverTask == nil { + hoverTask = Task { + try? await Task.sleep(for: .milliseconds(100)) + isHovered = true + hoverTask = nil + } } + } else { + hoverTask?.cancel() + hoverTask = nil + isHovered = false } - } else { - hoverTask?.cancel() - hoverTask = nil - isHovered = false } - } - .opacity(isVisible ? (configuration.isPressed ? 0.8 : 1.0) : 0.0) - .foregroundColor(fgColor.opacity(0.8)) - .frame(height: 32.0) - .animation(.easeInOut, value: isVisible) - .animation(.easeInOut, value: configuration.isPressed) + .animation(.easeInOut, value: isHovered) + .opacity(isVisible ? (configuration.isPressed ? 0.8 : 1.0) : 0.0) + .foregroundColor(fgColor.opacity(0.8)) + .frame(height: 32.0) + .animation(.easeInOut, value: isVisible) + .animation(.easeInOut, value: configuration.isPressed) } } diff --git a/Pika/Views/ContentView.swift b/Pika/Views/ContentView.swift index 34fe8b7b..4caebe91 100644 --- a/Pika/Views/ContentView.swift +++ b/Pika/Views/ContentView.swift @@ -1,3 +1,4 @@ +import Combine import Defaults import SwiftUI @@ -10,8 +11,8 @@ struct ContentView: View { let pasteboard = NSPasteboard.general @State var swapVisible: Bool = false - @State private var swapHideTask: Task? - @State private var swapButtonHovered: Bool = false + @State private var swapTimerSubscription: Cancellable? + @State private var swapTimer = Timer.publish(every: 0.25, on: .main, in: .common) @State private var angle: Double = 0 var body: some View { @@ -19,17 +20,15 @@ struct ContentView: View { Divider() ColorPickers() .onHover { hover in - if hover { - swapHideTask?.cancel() - swapHideTask = nil - swapVisible = true - } else if swapHideTask == nil && !swapButtonHovered { - swapHideTask = Task { - try? await Task.sleep(for: .milliseconds(250)) - swapVisible = false - swapHideTask = nil - } - } + guard hover else { return } + swapVisible = true + swapTimerSubscription?.cancel() + swapTimerSubscription = nil + } + .onReceive(swapTimer) { _ in + swapVisible = false + swapTimerSubscription?.cancel() + swapTimerSubscription = nil } .overlay( Button(action: { @@ -43,11 +42,7 @@ struct ContentView: View { .buttonStyle(SwapButtonStyle( isVisible: swapVisible, alt: PikaText.textColorSwap, - ltr: true, - onHoverChange: { hover in - swapButtonHovered = hover - if hover { swapHideTask?.cancel(); swapHideTask = nil } - } + ltr: true )) .onReceive(NotificationCenter.default.publisher(for: .triggerSwap)) { _ in swap(&eyedroppers.foreground.color, &eyedroppers.background.color) @@ -56,6 +51,13 @@ struct ContentView: View { .padding(16.0) .frame(maxHeight: .infinity, alignment: .top) ) + .onHover { hover in + guard !hover, swapTimerSubscription == nil else { + return + } + swapTimer = Timer.publish(every: 0.25, on: .main, in: .common) + swapTimerSubscription = swapTimer.connect() + } Divider() Footer(foreground: eyedroppers.foreground, background: eyedroppers.background) diff --git a/Pika/Views/EyedropperButton.swift b/Pika/Views/EyedropperButton.swift index 0e21a39a..51d73ea0 100644 --- a/Pika/Views/EyedropperButton.swift +++ b/Pika/Views/EyedropperButton.swift @@ -94,7 +94,7 @@ struct EyedropperButton: View { hoverTask?.cancel() hoverTask = nil hoverVisible = true - } else if hoverTask == nil && !childHovered { + } else if hoverTask == nil, !childHovered { hoverTask = Task { try? await Task.sleep(for: .milliseconds(250)) hoverVisible = false diff --git a/PikaTests/APCAComplianceTests.swift b/PikaTests/APCAComplianceTests.swift index d5a5a45d..ecb3fb9f 100644 --- a/PikaTests/APCAComplianceTests.swift +++ b/PikaTests/APCAComplianceTests.swift @@ -30,7 +30,7 @@ final class APCAComplianceTests: XCTestCase { let pairs: [(NSColor, NSColor)] = [ (NSColor(r: 0, g: 0, b: 0), NSColor(r: 0, g: 0, b: 0)), (NSColor(r: 50, g: 50, b: 50), NSColor(r: 240, g: 240, b: 240)), - (NSColor(r: 0, g: 0, b: 0), NSColor(r: 255, g: 255, b: 255)) + (NSColor(r: 0, g: 0, b: 0), NSColor(r: 255, g: 255, b: 255)), ] for (foreground, background) in pairs { let level = foreground.APCACompliance(with: background).level From bc9a4b9924a6696a0f808be1cfda95f46e9dcd6e Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Tue, 10 Mar 2026 09:29:06 -0700 Subject: [PATCH 18/18] Fix hover button animations: smooth expand/collapse with cooldown and cancellation guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Animate isHovered transitions with easeInOut(0.15s) for smooth icon→label expand - Add hoverCooldown (150ms) after cursor exits to block animation-induced re-entry - Fix Task cancellation: guard !Task.isCancelled after try? sleep so cancelled tasks don't execute their side-effects (was causing labels to get stuck open) - Add onHoverChange callback to propagate child hover state to parent - Use childHovered flag in EyedropperButton to suppress premature hide task - Use Timer.publish in ContentView for stable swap button visibility Co-Authored-By: Claude Opus 4.6 --- Pika/ButtonStyles/SwapButtonStyle.swift | 85 +++++++++++++------------ Pika/Views/EyedropperButton.swift | 1 + 2 files changed, 47 insertions(+), 39 deletions(-) diff --git a/Pika/ButtonStyles/SwapButtonStyle.swift b/Pika/ButtonStyles/SwapButtonStyle.swift index 4a5e9170..e688caf3 100644 --- a/Pika/ButtonStyles/SwapButtonStyle.swift +++ b/Pika/ButtonStyles/SwapButtonStyle.swift @@ -11,6 +11,7 @@ struct SwapButtonStyle: ButtonStyle { @State private var isHovered: Bool = false @State private var hoverTask: Task? + @State private var hoverCooldown: Task? let configuration: Configuration let isVisible: Bool @@ -38,48 +39,54 @@ struct SwapButtonStyle: ButtonStyle { } configuration.label } - }.animation(.easeInOut, value: isHovered) - .padding(.horizontal, 8) - .padding(.vertical, 8) - .mask(RoundedRectangle(cornerRadius: 100.0, style: .continuous)) - .background( - ZStack { - RoundedRectangle(cornerRadius: 100.0, style: .continuous) - .fill(bgColor) - .shadow( - color: Color.black.opacity(0.2), - radius: configuration.isPressed ? 1 : 2, - x: 0, - y: configuration.isPressed ? 1 : 2 - ) - .overlay( - RoundedRectangle(cornerRadius: 100.0, style: .continuous) - .stroke(fgColor.opacity(0.1)) - ) - } - ) - .onHover { hover in - onHoverChange?(hover) - if hover { - if hoverTask == nil { - hoverTask = Task { - try? await Task.sleep(for: .milliseconds(100)) - isHovered = true - hoverTask = nil - } - } - } else { - hoverTask?.cancel() + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + .mask(RoundedRectangle(cornerRadius: 100.0, style: .continuous)) + .background( + ZStack { + RoundedRectangle(cornerRadius: 100.0, style: .continuous) + .fill(bgColor) + .shadow( + color: Color.black.opacity(0.2), + radius: configuration.isPressed ? 1 : 2, + x: 0, + y: configuration.isPressed ? 1 : 2 + ) + .overlay( + RoundedRectangle(cornerRadius: 100.0, style: .continuous) + .stroke(fgColor.opacity(0.1)) + ) + } + ) + .onHover { hover in + onHoverChange?(hover) + if hover { + guard hoverCooldown == nil, hoverTask == nil else { return } + hoverTask = Task { + try? await Task.sleep(for: .milliseconds(100)) + guard !Task.isCancelled else { return } + isHovered = true hoverTask = nil - isHovered = false + } + } else { + hoverTask?.cancel() + hoverTask = nil + isHovered = false + hoverCooldown?.cancel() + hoverCooldown = Task { + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled else { return } + hoverCooldown = nil } } - .animation(.easeInOut, value: isHovered) - .opacity(isVisible ? (configuration.isPressed ? 0.8 : 1.0) : 0.0) - .foregroundColor(fgColor.opacity(0.8)) - .frame(height: 32.0) - .animation(.easeInOut, value: isVisible) - .animation(.easeInOut, value: configuration.isPressed) + } + .opacity(isVisible ? (configuration.isPressed ? 0.8 : 1.0) : 0.0) + .foregroundColor(fgColor.opacity(0.8)) + .frame(height: 32.0) + .animation(.easeInOut(duration: 0.15), value: isHovered) + .animation(.easeInOut, value: isVisible) + .animation(.easeInOut, value: configuration.isPressed) } } diff --git a/Pika/Views/EyedropperButton.swift b/Pika/Views/EyedropperButton.swift index 51d73ea0..ef870a2c 100644 --- a/Pika/Views/EyedropperButton.swift +++ b/Pika/Views/EyedropperButton.swift @@ -97,6 +97,7 @@ struct EyedropperButton: View { } else if hoverTask == nil, !childHovered { hoverTask = Task { try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } hoverVisible = false hoverTask = nil }