├── .spi.yml ├── .gitignore ├── Sources └── ColorWellKit │ ├── Documentation.docc │ ├── Resources │ │ ├── grid-view.png │ │ ├── default-style@2x.png │ │ ├── expanded-style@2x.png │ │ ├── default-style~dark@2x.png │ │ ├── design-comparison-dark.png │ │ ├── expanded-style~dark@2x.png │ │ ├── custom-swatch-colors@2x.png │ │ ├── design-comparison-light.png │ │ ├── color-well-with-popover-dark.png │ │ ├── color-well-with-popover-light.png │ │ └── custom-swatch-colors~dark@2x.png │ ├── SwiftUI │ │ ├── ColorWellStyle │ │ │ ├── DefaultColorWellStyle.md │ │ │ ├── MinimalColorWellStyle.md │ │ │ ├── ExpandedColorWellStyle.md │ │ │ └── ColorWellStyle.md │ │ ├── ColorPanelMode.md │ │ └── ColorWell.md │ ├── Cocoa │ │ ├── CWColorWell.Style │ │ │ ├── Style.md │ │ │ └── Style.init(rawValue:).md │ │ ├── CWColorWell.md │ │ └── ColorObservation.md │ └── ColorWellKit.md │ ├── Views │ ├── SwiftUI │ │ ├── ColorWellSecondaryActionDelegate.swift │ │ ├── Backports.swift │ │ ├── EnvironmentValues.swift │ │ ├── ViewModifiers.swift │ │ ├── ColorWellStyle.swift │ │ ├── ColorPanelMode.swift │ │ ├── ColorWell.swift │ │ └── ColorWellRepresentable.swift │ └── Cocoa │ │ ├── CWColorWell.Style.swift │ │ ├── CWColorWellDelegate.swift │ │ ├── CWColorWellLayoutView.swift │ │ ├── CWColorWellBaseControl.swift │ │ ├── CWColorWell.swift │ │ ├── CWColorWellPopover.swift │ │ └── CWColorWellSegment.swift │ └── Utilities │ ├── ObjectAssociation.swift │ ├── Logging.swift │ ├── LocalEventMonitor.swift │ ├── Geometry.swift │ ├── LockedState.swift │ ├── ColorHelpers.swift │ ├── Path.swift │ └── Extensions.swift ├── Package.swift ├── .github └── workflows │ ├── test.yml │ └── swiftlint.yml ├── Tests └── ColorWellKitTests │ └── ColorWellKitTests.swift ├── LICENSE ├── .swiftlint.yml └── README.md /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [ColorWellKit] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode 9 | .netrc 10 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Resources/grid-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWellKit/HEAD/Sources/ColorWellKit/Documentation.docc/Resources/grid-view.png -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Resources/default-style@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWellKit/HEAD/Sources/ColorWellKit/Documentation.docc/Resources/default-style@2x.png -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Resources/expanded-style@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWellKit/HEAD/Sources/ColorWellKit/Documentation.docc/Resources/expanded-style@2x.png -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Resources/default-style~dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWellKit/HEAD/Sources/ColorWellKit/Documentation.docc/Resources/default-style~dark@2x.png -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Resources/design-comparison-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWellKit/HEAD/Sources/ColorWellKit/Documentation.docc/Resources/design-comparison-dark.png -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Resources/expanded-style~dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWellKit/HEAD/Sources/ColorWellKit/Documentation.docc/Resources/expanded-style~dark@2x.png -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Resources/custom-swatch-colors@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWellKit/HEAD/Sources/ColorWellKit/Documentation.docc/Resources/custom-swatch-colors@2x.png -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Resources/design-comparison-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWellKit/HEAD/Sources/ColorWellKit/Documentation.docc/Resources/design-comparison-light.png -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Resources/color-well-with-popover-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWellKit/HEAD/Sources/ColorWellKit/Documentation.docc/Resources/color-well-with-popover-dark.png -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Resources/color-well-with-popover-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWellKit/HEAD/Sources/ColorWellKit/Documentation.docc/Resources/color-well-with-popover-light.png -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Resources/custom-swatch-colors~dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWellKit/HEAD/Sources/ColorWellKit/Documentation.docc/Resources/custom-swatch-colors~dark@2x.png -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/SwiftUI/ColorWellStyle/DefaultColorWellStyle.md: -------------------------------------------------------------------------------- 1 | # ``ColorWellKit/DefaultColorWellStyle`` 2 | 3 | ## Topics 4 | 5 | ### Getting the style 6 | 7 | - ``ColorWellStyle/default`` 8 | 9 | ### Creating the style 10 | 11 | - ``init()`` 12 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/SwiftUI/ColorWellStyle/MinimalColorWellStyle.md: -------------------------------------------------------------------------------- 1 | # ``ColorWellKit/MinimalColorWellStyle`` 2 | 3 | ## Topics 4 | 5 | ### Getting the style 6 | 7 | - ``ColorWellStyle/minimal`` 8 | 9 | ### Creating the style 10 | 11 | - ``init()`` 12 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/SwiftUI/ColorWellStyle/ExpandedColorWellStyle.md: -------------------------------------------------------------------------------- 1 | # ``ColorWellKit/ExpandedColorWellStyle`` 2 | 3 | ## Topics 4 | 5 | ### Getting the style 6 | 7 | - ``ColorWellStyle/expanded`` 8 | 9 | ### Creating the style 10 | 11 | - ``init()`` 12 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Cocoa/CWColorWell.Style/Style.md: -------------------------------------------------------------------------------- 1 | # ``ColorWellKit/CWColorWell/Style-swift.enum`` 2 | 3 | ## Topics 4 | 5 | ### Getting the available styles 6 | 7 | - ``default`` 8 | - ``minimal`` 9 | - ``expanded`` 10 | 11 | ### Creating a style from a raw value 12 | 13 | - ``init(rawValue:)`` 14 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/SwiftUI/ColorWellStyle/ColorWellStyle.md: -------------------------------------------------------------------------------- 1 | # ``ColorWellKit/ColorWellStyle`` 2 | 3 | ## Topics 4 | 5 | ### Color well style types 6 | 7 | - ``DefaultColorWellStyle`` 8 | - ``MinimalColorWellStyle`` 9 | - ``ExpandedColorWellStyle`` 10 | 11 | ### Creating a color well style 12 | 13 | - ``ColorWellStyle/default`` 14 | - ``ColorWellStyle/minimal`` 15 | - ``ColorWellStyle/expanded`` 16 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/SwiftUI/ColorWellSecondaryActionDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellSecondaryActionDelegate.swift 3 | // ColorWellKit 4 | // 5 | 6 | import Foundation 7 | 8 | class ColorWellSecondaryActionDelegate: NSObject { 9 | private let action: () -> Void 10 | 11 | init(action: @escaping () -> Void) { 12 | self.action = action 13 | } 14 | 15 | @objc func performAction() { 16 | action() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ColorWellKit", 7 | platforms: [ 8 | .macOS(.v10_13), 9 | ], 10 | products: [ 11 | .library( 12 | name: "ColorWellKit", 13 | targets: ["ColorWellKit"] 14 | ), 15 | ], 16 | targets: [ 17 | .target( 18 | name: "ColorWellKit" 19 | ), 20 | .testTarget( 21 | name: "ColorWellKitTests", 22 | dependencies: ["ColorWellKit"] 23 | ), 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - ".github/workflows/test.yml" 9 | - "**/*.swift" 10 | pull_request: 11 | paths: 12 | - ".github/workflows/test.yml" 13 | - "**/*.swift" 14 | 15 | jobs: 16 | test: 17 | if: '!github.event.pull_request.merged' 18 | runs-on: macos-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: swift-actions/setup-swift@v1 22 | - name: Check Swift Version 23 | run: swift --version 24 | - name: Run Tests 25 | run: swift test -v 26 | -------------------------------------------------------------------------------- /.github/workflows/swiftlint.yml: -------------------------------------------------------------------------------- 1 | name: SwiftLint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - ".github/workflows/swiftlint.yml" 9 | - ".swiftlint.yml" 10 | - "**/*.swift" 11 | pull_request: 12 | paths: 13 | - ".github/workflows/swiftlint.yml" 14 | - ".swiftlint.yml" 15 | - "**/*.swift" 16 | 17 | jobs: 18 | swiftlint: 19 | if: '!github.event.pull_request.merged' 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Run SwiftLint 24 | uses: norio-nomura/action-swiftlint@3.2.1 25 | -------------------------------------------------------------------------------- /Tests/ColorWellKitTests/ColorWellKitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellKitTests.swift 3 | // ColorWellKit 4 | // 5 | 6 | import XCTest 7 | @testable import ColorWellKit 8 | 9 | final class ColorWellKitTests: XCTestCase { 10 | func testCGRectCenter() { 11 | let rect1 = CGRect(x: 0, y: 0, width: 500, height: 500) 12 | let rect2 = CGRect(x: 1000, y: 1000, width: 250, height: 250) 13 | let rect3 = rect2.centered(in: rect1) 14 | XCTAssertEqual(rect3.origin.x, rect1.midX - (rect3.width / 2)) 15 | XCTAssertEqual(rect3.origin.y, rect1.midY - (rect3.height / 2)) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/SwiftUI/ColorPanelMode.md: -------------------------------------------------------------------------------- 1 | # ``ColorWellKit/ColorPanelMode`` 2 | 3 | ## Topics 4 | 5 | ### Color panel mode types 6 | 7 | - ``GrayscaleColorPanelMode`` 8 | - ``RGBColorPanelMode`` 9 | - ``CMYKColorPanelMode`` 10 | - ``HSBColorPanelMode`` 11 | - ``CustomPaletteColorPanelMode`` 12 | - ``ColorListColorPanelMode`` 13 | - ``ColorWheelColorPanelMode`` 14 | - ``CrayonPickerColorPanelMode`` 15 | 16 | ### Creating a color panel mode 17 | 18 | - ``ColorPanelMode/gray`` 19 | - ``ColorPanelMode/rgb`` 20 | - ``ColorPanelMode/cmyk`` 21 | - ``ColorPanelMode/hsb`` 22 | - ``ColorPanelMode/customPalette`` 23 | - ``ColorPanelMode/colorList`` 24 | - ``ColorPanelMode/wheel`` 25 | - ``ColorPanelMode/crayon`` 26 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Cocoa/CWColorWell.Style/Style.init(rawValue:).md: -------------------------------------------------------------------------------- 1 | # ``ColorWellKit/CWColorWell/Style-swift.enum/init(rawValue:)`` 2 | 3 | Creates a new color well style with the specified raw value. 4 | 5 | If the specified raw value does not correspond to any of the existing color well styles, this initializer returns `nil`. 6 | 7 | ```swift 8 | print(Style(rawValue: 0)) 9 | // Prints "Optional(Style.default)" 10 | 11 | print(Style(rawValue: 1)) 12 | // Prints "Optional(Style.minimal)" 13 | 14 | print(Style(rawValue: 2)) 15 | // Prints "Optional(Style.expanded)" 16 | 17 | print(Style(rawValue: 3)) 18 | // Prints "nil" 19 | ``` 20 | 21 | - Parameter rawValue: The raw value to use for the new instance. 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jordan Baird 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Cocoa/CWColorWell.md: -------------------------------------------------------------------------------- 1 | # ``ColorWellKit/CWColorWell`` 2 | 3 | ## Overview 4 | 5 | Color wells provide an interface in your app for users to select custom colors. A color well displays the currently selected color, and provides options for selecting new colors. There are a number of styles to choose from, letting you customize the color well's appearance and behavior. You can also observe the color well's color and execute custom code when the color changes (see ). 6 | 7 | ## Topics 8 | 9 | ### Creating a color well 10 | 11 | - ``init(style:)`` 12 | - ``init(color:)`` 13 | 14 | ### Configuring the color well 15 | 16 | - ``allowsMultipleSelection`` 17 | - ``colorPanelMode`` 18 | - ``delegate`` 19 | - ``secondaryAction`` 20 | - ``secondaryTarget`` 21 | - ``style-swift.property`` 22 | - ``swatchColors`` 23 | 24 | ### Accessing the current color 25 | 26 | - ``color`` 27 | - 28 | 29 | ### Color well activation 30 | 31 | - ``isActive`` 32 | - ``activate(exclusive:)`` 33 | - ``deactivate()`` 34 | 35 | ### Supporting Types 36 | 37 | - ``CWColorWellDelegate`` 38 | - ``Style-swift.enum`` 39 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Utilities/ObjectAssociation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectAssociation.swift 3 | // ColorWellKit 4 | // 5 | 6 | import ObjectiveC 7 | 8 | // MARK: - AssociationPolicy 9 | 10 | enum AssociationPolicy { 11 | case assign 12 | case copy 13 | case copyNonatomic 14 | case retain 15 | case retainNonatomic 16 | 17 | fileprivate var objcValue: objc_AssociationPolicy { 18 | switch self { 19 | case .assign: .OBJC_ASSOCIATION_ASSIGN 20 | case .copy: .OBJC_ASSOCIATION_COPY 21 | case .copyNonatomic: .OBJC_ASSOCIATION_COPY_NONATOMIC 22 | case .retain: .OBJC_ASSOCIATION_RETAIN 23 | case .retainNonatomic: .OBJC_ASSOCIATION_RETAIN_NONATOMIC 24 | } 25 | } 26 | } 27 | 28 | // MARK: - ObjectAssociation 29 | 30 | final class ObjectAssociation { 31 | private let policy: AssociationPolicy 32 | 33 | private var key: UnsafeRawPointer { 34 | UnsafeRawPointer(Unmanaged.passUnretained(self).toOpaque()) 35 | } 36 | 37 | init(policy: AssociationPolicy = .retainNonatomic) { 38 | self.policy = policy 39 | } 40 | 41 | subscript(object: AnyObject) -> Value? { 42 | get { objc_getAssociatedObject(object, key) as? Value } 43 | set { objc_setAssociatedObject(object, key, newValue, policy.objcValue) } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Utilities/Logging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logging.swift 3 | // ColorWellKit 4 | // 5 | 6 | import Foundation 7 | import os 8 | 9 | private let subsystem: String = { 10 | let packageName = "ColorWellKit" 11 | guard let bundleIdentifier = Bundle.main.bundleIdentifier else { 12 | return packageName 13 | } 14 | return "\(bundleIdentifier).\(packageName)" 15 | }() 16 | 17 | /// A value that the logging system uses to filter log messages. 18 | struct LogCategory: RawRepresentable { 19 | let rawValue: String 20 | let log: OSLog 21 | 22 | init(rawValue: String) { 23 | self.rawValue = rawValue 24 | self.log = OSLog(subsystem: subsystem, category: rawValue) 25 | } 26 | } 27 | 28 | extension LogCategory { 29 | /// The main log category. 30 | static let main = LogCategory(rawValue: "main") 31 | 32 | /// The log category to use for the `CWColorWellPopover` type. 33 | static let popover = LogCategory(rawValue: "CWColorWellPopover") 34 | 35 | /// The log category to use for the `CWColorComponents` type. 36 | static let components = LogCategory(rawValue: "CWColorComponents") 37 | } 38 | 39 | /// Sends a message to the logging system using the given category and log level. 40 | func cw_log(_ message: String, category: LogCategory = .main, type: OSLogType = .default) { 41 | os_log("%@", log: category.log, type: type, message) 42 | } 43 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Utilities/LocalEventMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalEventMonitor.swift 3 | // ColorWellKit 4 | // 5 | 6 | import AppKit 7 | 8 | /// A type that monitors for events within the scope of the current process. 9 | class LocalEventMonitor { 10 | private let mask: NSEvent.EventTypeMask 11 | private let handler: (NSEvent) -> NSEvent? 12 | private var monitor: Any? 13 | 14 | /// Creates an event monitor with the given event type mask and handler. 15 | /// 16 | /// - Parameters: 17 | /// - mask: An event type mask specifying which events to monitor. 18 | /// - handler: A handler to execute when the event monitor receives 19 | /// an event corresponding to the event types in `mask`. 20 | init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent) -> NSEvent?) { 21 | self.mask = mask 22 | self.handler = handler 23 | } 24 | 25 | deinit { 26 | stop() 27 | } 28 | 29 | /// Starts monitoring for events. 30 | func start() { 31 | guard monitor == nil else { 32 | return 33 | } 34 | monitor = NSEvent.addLocalMonitorForEvents( 35 | matching: mask, 36 | handler: handler 37 | ) 38 | } 39 | 40 | /// Stops monitoring for events. 41 | func stop() { 42 | guard let monitor else { 43 | return 44 | } 45 | NSEvent.removeMonitor(monitor) 46 | self.monitor = nil 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/SwiftUI/Backports.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Backports.swift 3 | // ColorWellKit 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import SwiftUI 8 | 9 | /// A namespace for backported `SwiftUI` functionality. 10 | enum Backports { } 11 | 12 | @available(macOS 10.15, *) 13 | private enum ControlAlignment: AlignmentID { 14 | static func defaultValue(in context: ViewDimensions) -> CGFloat { 15 | context[HorizontalAlignment.center] 16 | } 17 | } 18 | 19 | @available(macOS 10.15, *) 20 | private extension HorizontalAlignment { 21 | static let controlAlignment = HorizontalAlignment(ControlAlignment.self) 22 | } 23 | 24 | @available(macOS 10.15, *) 25 | extension Backports { 26 | struct LabeledContent: View { 27 | private let label: Label 28 | private let content: Content 29 | 30 | var body: some View { 31 | HStack(alignment: .firstTextBaseline) { 32 | label 33 | content 34 | .labelsHidden() 35 | .alignmentGuide(.controlAlignment) { context in 36 | context[.leading] 37 | } 38 | } 39 | .alignmentGuide(.leading) { context in 40 | context[.controlAlignment] 41 | } 42 | } 43 | 44 | init(@ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) { 45 | self.label = label() 46 | self.content = content() 47 | } 48 | } 49 | } 50 | #endif 51 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/Cocoa/CWColorWell.Style.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Style.swift 3 | // ColorWellKit 4 | // 5 | 6 | import Foundation 7 | 8 | extension CWColorWell { 9 | /// Constants that specify the appearance and behavior of a color well. 10 | @objc public enum Style: Int { 11 | /// The color well is displayed as a rectangular control that displays 12 | /// the selected color and shows the system color panel when clicked. 13 | case `default` = 0 14 | 15 | /// The color well is displayed as a rectangular control that displays 16 | /// the selected color and shows a popover containing the color well's 17 | /// swatch colors when clicked. 18 | /// 19 | /// The popover contains an option to show the system color panel. 20 | case minimal = 1 21 | 22 | /// The color well is displayed as a segmented control that displays 23 | /// the selected color alongside a dedicated button to show the system 24 | /// color panel. 25 | /// 26 | /// Clicking inside the color area displays a popover containing the 27 | /// color well's swatch colors. 28 | case expanded = 2 29 | } 30 | } 31 | 32 | extension CWColorWell.Style: CustomStringConvertible { 33 | public var description: String { 34 | let prefix = String(describing: Self.self) + "." 35 | return switch self { 36 | case .default: prefix + "default" 37 | case .minimal: prefix + "minimal" 38 | case .expanded: prefix + "expanded" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Sources 3 | - Tests 4 | 5 | disabled_rules: 6 | - cyclomatic_complexity 7 | - file_length 8 | - function_body_length 9 | - identifier_name 10 | - large_tuple 11 | - line_length 12 | - todo 13 | - type_body_length 14 | 15 | opt_in_rules: 16 | - closure_end_indentation 17 | - closure_spacing 18 | - collection_alignment 19 | - convenience_type 20 | - discouraged_object_literal 21 | - empty_count 22 | - fatal_error_message 23 | - file_header 24 | - force_unwrapping 25 | - implicitly_unwrapped_optional 26 | - indentation_width 27 | - literal_expression_end_indentation 28 | - lower_acl_than_parent 29 | - modifier_order 30 | - multiline_arguments 31 | - multiline_arguments_brackets 32 | - multiline_literal_brackets 33 | - multiline_parameters 34 | - multiline_parameters_brackets 35 | - period_spacing 36 | - unavailable_function 37 | - vertical_parameter_alignment_on_call 38 | - vertical_whitespace_closing_braces 39 | - yoda_condition 40 | 41 | custom_rules: 42 | objc_dynamic: 43 | name: "@objc dynamic" 44 | message: "`dynamic` modifier should immediately follow `@objc` attribute" 45 | regex: '@objc\b(\(\w*\))?+\s*(\S+|\v+\S*)\s*\bdynamic' 46 | match_kinds: attribute.builtin 47 | prefer_spaces_over_tabs: 48 | name: Prefer Spaces Over Tabs 49 | message: Indentation should use 4 spaces per indentation level instead of tabs 50 | regex: ^\t 51 | 52 | file_header: 53 | required_pattern: | 54 | // 55 | // SWIFTLINT_CURRENT_FILENAME 56 | // ColorWellKit 57 | // 58 | 59 | modifier_order: 60 | preferred_modifier_order: 61 | - acl 62 | - setterACL 63 | - override 64 | - mutators 65 | - lazy 66 | - final 67 | - required 68 | - convenience 69 | - typeMethods 70 | - owned 71 | 72 | trailing_comma: 73 | mandatory_comma: true 74 | 75 | type_name: 76 | allowed_symbols: ["_"] 77 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/Cocoa/CWColorWellDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CWColorWellDelegate.swift 3 | // ColorWellKit 4 | // 5 | 6 | import AppKit 7 | 8 | /// A delegate object that communicates changes to and from a ``CWColorWell``. 9 | public protocol CWColorWellDelegate: AnyObject { 10 | /// Informs the delegate that the color well's color is about to change. 11 | /// 12 | /// You can access the color well's current color using the ``CWColorWell/color`` 13 | /// property on the `colorWell` parameter. 14 | /// 15 | /// - Parameters: 16 | /// - colorWell: The color well whose color is about to change. 17 | /// - newColor: The color well's new color. 18 | func colorWellWillChangeColor(_ colorWell: CWColorWell, to newColor: NSColor) 19 | 20 | /// Informs the delegate that the color well's color has changed. 21 | /// 22 | /// You can access the color well's current color using the ``CWColorWell/color`` 23 | /// property on the `colorWell` parameter. 24 | /// 25 | /// - Parameter colorWell: The color well whose color has changed. 26 | func colorWellDidChangeColor(_ colorWell: CWColorWell) 27 | 28 | /// Informs the delegate that the color well has been activated. 29 | /// 30 | /// - Parameter colorWell: The activated color well. 31 | func colorWellDidActivate(_ colorWell: CWColorWell) 32 | 33 | /// Informs the delegate that the color well has been deactivated. 34 | /// 35 | /// - Parameter colorWell: The deactivated color well. 36 | func colorWellDidDeactivate(_ colorWell: CWColorWell) 37 | } 38 | 39 | // MARK: Default Implementations 40 | extension CWColorWellDelegate { 41 | public func colorWellWillChangeColor(_ colorWell: CWColorWell, to newColor: NSColor) { } 42 | 43 | public func colorWellDidChangeColor(_ colorWell: CWColorWell) { } 44 | 45 | public func colorWellDidActivate(_ colorWell: CWColorWell) { } 46 | 47 | public func colorWellDidDeactivate(_ colorWell: CWColorWell) { } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/SwiftUI/EnvironmentValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentValues.swift 3 | // ColorWellKit 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import SwiftUI 8 | 9 | @available(macOS 10.15, *) 10 | private struct ColorWellStyleConfigurationKey: EnvironmentKey { 11 | static let defaultValue = _ColorWellStyleConfiguration.default 12 | } 13 | 14 | @available(macOS 10.15, *) 15 | private struct ColorWellSwatchColorsKey: EnvironmentKey { 16 | static let defaultValue: [NSColor]? = nil 17 | } 18 | 19 | @available(macOS 10.15, *) 20 | private struct ColorWellSecondaryActionDelegateKey: EnvironmentKey { 21 | static let defaultValue: ColorWellSecondaryActionDelegate? = nil 22 | } 23 | 24 | @available(macOS 10.15, *) 25 | private struct ColorPanelModeConfigurationKey: EnvironmentKey { 26 | static var defaultValue: _ColorPanelModeConfiguration? 27 | } 28 | 29 | @available(macOS 10.15, *) 30 | extension EnvironmentValues { 31 | var colorWellStyleConfiguration: _ColorWellStyleConfiguration { 32 | get { self[ColorWellStyleConfigurationKey.self] } 33 | set { self[ColorWellStyleConfigurationKey.self] = newValue } 34 | } 35 | } 36 | 37 | @available(macOS 10.15, *) 38 | extension EnvironmentValues { 39 | var colorWellSwatchColors: [NSColor]? { 40 | get { self[ColorWellSwatchColorsKey.self] } 41 | set { self[ColorWellSwatchColorsKey.self] = newValue } 42 | } 43 | } 44 | 45 | @available(macOS 10.15, *) 46 | extension EnvironmentValues { 47 | var colorWellSecondaryActionDelegate: ColorWellSecondaryActionDelegate? { 48 | get { self[ColorWellSecondaryActionDelegateKey.self] } 49 | set { self[ColorWellSecondaryActionDelegateKey.self] = newValue } 50 | } 51 | } 52 | 53 | @available(macOS 10.15, *) 54 | extension EnvironmentValues { 55 | var colorPanelModeConfiguration: _ColorPanelModeConfiguration? { 56 | get { self[ColorPanelModeConfigurationKey.self] } 57 | set { self[ColorPanelModeConfigurationKey.self] = newValue } 58 | } 59 | } 60 | #endif 61 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/ColorWellKit.md: -------------------------------------------------------------------------------- 1 | # ``ColorWellKit`` 2 | 3 | A versatile alternative to `NSColorWell` for Cocoa and `ColorPicker` for SwiftUI. 4 | 5 | ## Overview 6 | 7 | ColorWellKit is designed to mimic the appearance and behavior of the color well designs introduced in macOS 13 Ventura, ideal for use in apps that are unable to target the latest SDK. 8 | 9 | | Light mode | Dark mode | 10 | | --------------- | -------------- | 11 | | ![][light-mode] | ![][dark-mode] | 12 | 13 | ## SwiftUI 14 | 15 | Create a ``ColorWell`` and add it to your view hierarchy. There are a wide range of initializers, as well as several modifiers to choose from, allowing you to set the color well's color, label, and style. 16 | 17 | ```swift 18 | import SwiftUI 19 | import ColorWellKit 20 | 21 | struct ContentView: View { 22 | @Binding var fontColor: Color 23 | 24 | var body: some View { 25 | VStack { 26 | ColorWell("Font Color", selection: $fontColor) 27 | .colorWellStyle(.expanded) 28 | 29 | MyCustomTextEditor(fontColor: fontColor) 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | ## Cocoa 36 | 37 | Create a ``CWColorWell`` using one of the available initializers. Respond to color changes using your preferred design pattern (see ): 38 | 39 | ```swift 40 | import Cocoa 41 | import ColorWellKit 42 | 43 | class ContentViewController: NSViewController { 44 | @IBOutlet var textControls: NSStackView! 45 | @IBOutlet var textEditor: MyCustomNSTextEditor! 46 | 47 | private var colorObservation: NSKeyValueObservation? 48 | 49 | override func viewDidLoad() { 50 | let colorWell = CWColorWell(style: .expanded) 51 | colorWell.color = textEditor.fontColor 52 | 53 | colorObservation = colorWell.observe(\.color) { colorWell, _ in 54 | textEditor.fontColor = colorWell.color 55 | } 56 | 57 | textControls.addArrangedSubview(colorWell) 58 | } 59 | } 60 | ``` 61 | 62 | ## Topics 63 | 64 | ### Color Wells in SwiftUI 65 | 66 | - ``ColorWell`` 67 | 68 | ### Color Wells in Cocoa 69 | 70 | - ``CWColorWell`` 71 | 72 | [light-mode]: color-well-with-popover-light.png 73 | [dark-mode]: color-well-with-popover-dark.png 74 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Utilities/Geometry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Geometry.swift 3 | // ColorWellKit 4 | // 5 | 6 | import CoreGraphics 7 | 8 | // MARK: - Corner 9 | 10 | /// A type that represents a corner of a rectangle. 11 | enum Corner { 12 | /// The top leading corner of a rectangle. 13 | case topLeading 14 | 15 | /// The top trailing corner of a rectangle. 16 | case topTrailing 17 | 18 | /// The bottom leading corner of a rectangle. 19 | case bottomLeading 20 | 21 | /// The bottom trailing corner of a rectangle. 22 | case bottomTrailing 23 | 24 | /// All corners, in the order that they appear in a clockwise 25 | /// traversal around a rectangle. 26 | static let clockwiseOrder: [Corner] = [ 27 | .topLeading, 28 | .topTrailing, 29 | .bottomTrailing, 30 | .bottomLeading, 31 | ] 32 | 33 | /// Returns the point in the given rectangle that corresponds 34 | /// to this corner. 35 | func point(in rect: CGRect) -> CGPoint { 36 | switch self { 37 | case .topLeading: CGPoint(x: rect.minX, y: rect.maxY) 38 | case .topTrailing: CGPoint(x: rect.maxX, y: rect.maxY) 39 | case .bottomLeading: CGPoint(x: rect.minX, y: rect.minY) 40 | case .bottomTrailing: CGPoint(x: rect.maxX, y: rect.minY) 41 | } 42 | } 43 | } 44 | 45 | // MARK: - Edge 46 | 47 | /// A type that represents an edge of a rectangle. 48 | enum Edge { 49 | /// The top edge of a rectangle. 50 | case top 51 | 52 | /// The bottom edge of a rectangle. 53 | case bottom 54 | 55 | /// The leading edge of a rectangle. 56 | case leading 57 | 58 | /// The trailing edge of a rectangle. 59 | case trailing 60 | 61 | /// The corners that, when connected by a path, make up this edge. 62 | var corners: [Corner] { 63 | switch self { 64 | case .top: [.topLeading, .topTrailing] 65 | case .bottom: [.bottomLeading, .bottomTrailing] 66 | case .leading: [.topLeading, .bottomLeading] 67 | case .trailing: [.topTrailing, .bottomTrailing] 68 | } 69 | } 70 | 71 | /// The edge at the opposite end of the rectangle. 72 | var opposite: Edge { 73 | switch self { 74 | case .top: .bottom 75 | case .bottom: .top 76 | case .leading: .trailing 77 | case .trailing: .leading 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Utilities/LockedState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LockedState.swift 3 | // ColorWellKit 4 | // 5 | 6 | import os 7 | 8 | /// A locking wrapper around a state. 9 | /// 10 | /// This implementation is heavily inspired by Foundation's `LockedState` type: 11 | /// https://github.com/apple/swift-foundation/blob/main/Sources/FoundationEssentials/LockedState.swift 12 | struct LockedState { 13 | private enum Lock { 14 | typealias UnfairLock = os_unfair_lock 15 | 16 | static func initialize(_ lock: UnsafeMutablePointer) { 17 | lock.initialize(to: UnfairLock()) 18 | } 19 | 20 | static func deinitialize(_ lock: UnsafeMutablePointer) { 21 | lock.deinitialize(count: 1) 22 | } 23 | 24 | static func lock(_ lock: UnsafeMutablePointer) { 25 | os_unfair_lock_lock(lock) 26 | } 27 | 28 | static func unlock(_ lock: UnsafeMutablePointer) { 29 | os_unfair_lock_unlock(lock) 30 | } 31 | } 32 | 33 | private class Buffer: ManagedBuffer { 34 | deinit { 35 | withUnsafeMutablePointerToElements { lock in 36 | Lock.deinitialize(lock) 37 | } 38 | } 39 | } 40 | 41 | private let buffer: ManagedBuffer 42 | 43 | var state: State { 44 | buffer.withUnsafeMutablePointerToHeader { state in 45 | state.pointee 46 | } 47 | } 48 | 49 | init(initialState: State) { 50 | self.buffer = Buffer.create(minimumCapacity: 1) { buffer in 51 | buffer.withUnsafeMutablePointerToElements { lock in 52 | Lock.initialize(lock) 53 | } 54 | return initialState 55 | } 56 | } 57 | 58 | func withLock(_ body: (inout State) throws -> T) rethrows -> T { 59 | try buffer.withUnsafeMutablePointers { state, lock in 60 | Lock.lock(lock) 61 | defer { 62 | Lock.unlock(lock) 63 | } 64 | return try body(&state.pointee) 65 | } 66 | } 67 | 68 | func withLockExtendingLifetimeOfState(_ body: (inout State) throws -> T) rethrows -> T { 69 | try buffer.withUnsafeMutablePointers { state, lock in 70 | Lock.lock(lock) 71 | return try withExtendedLifetime(state.pointee) { 72 | defer { 73 | Lock.unlock(lock) 74 | } 75 | return try body(&state.pointee) 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/Cocoa/ColorObservation.md: -------------------------------------------------------------------------------- 1 | # Responding to Color Changes 2 | 3 | ## Overview 4 | 5 | ``CWColorWell`` provides support for several common design patterns: 6 | 7 | ### Key-value observing 8 | 9 | To implement key-value observing, call the `observe(_:options:changeHandler:)` method with a key path to the color well's ``CWColorWell/color`` property and store the returned observation. 10 | 11 | ```swift 12 | class MyCustomViewController: NSViewController { 13 | let colorWell = CWColorWell(style: .expanded) 14 | var observation: NSKeyValueObservation? 15 | 16 | override func viewDidLoad() { 17 | super.viewDidLoad() 18 | view.addSubview(colorWell) 19 | 20 | observation = colorWell.observe( 21 | \.color, 22 | options: [.new] 23 | ) { colorWell, change in 24 | print("Color changed to: \(change.newValue!)") 25 | } 26 | } 27 | } 28 | ``` 29 | 30 | For more information about key-value observing, see [Using Key-Value Observing in Swift](https://developer.apple.com/documentation/swift/using-key-value-observing-in-swift). 31 | 32 | ### Target-action 33 | 34 | To implement the target-action mechanism, assign a target object and an action message for the target to receive when the color well's color changes. 35 | 36 | ```swift 37 | class MyCustomViewController: NSViewController { 38 | let colorWell = CWColorWell(style: .expanded) 39 | 40 | override func viewDidLoad() { 41 | super.viewDidLoad() 42 | view.addSubview(colorWell) 43 | 44 | colorWell.target = self 45 | colorWell.action = #selector(colorDidChange(_:)) 46 | } 47 | 48 | @objc func colorDidChange(_ sender: CWColorWell) { 49 | print("Color changed to: \(sender.color)") 50 | } 51 | } 52 | ``` 53 | 54 | For more information about the target-action mechanism, see the [NSControl documentation](https://developer.apple.com/documentation/appkit/nscontrol). 55 | 56 | ### Combine publishers 57 | 58 | > Note: The [`Combine`](https://developer.apple.com/documentation/combine) framework is available starting in macOS 10.15. 59 | 60 | After importing `Combine`, call the `publisher(for:)` method with a key path to the color well's ``CWColorWell/color`` property. Chain the publisher to a call to `sink(receiveValue:)`, and store the returned `Cancellable` to retain the subscription. 61 | 62 | ```swift 63 | import Combine 64 | 65 | class MyCustomViewController: NSViewController { 66 | let colorWell = CWColorWell(style: .expanded) 67 | var cancellable: Cancellable? 68 | 69 | override func viewDidLoad() { 70 | super.viewDidLoad() 71 | view.addSubview(colorWell) 72 | 73 | cancellable = colorWell 74 | .publisher(for: \.color) 75 | .sink { color in 76 | print("Color changed to: \(color)") 77 | } 78 | } 79 | } 80 | ``` 81 | 82 | For more information about using publishers, see the [Combine documentation](https://developer.apple.com/documentation/combine). 83 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/SwiftUI/ViewModifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModifiers.swift 3 | // ColorWellKit 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import SwiftUI 8 | 9 | @available(macOS 10.15, *) 10 | extension View { 11 | /// Sets the style for color wells in this view. 12 | /// 13 | /// - Parameter style: The style to apply to the color wells. 14 | public func colorWellStyle(_ style: S) -> some View { 15 | environment(\.colorWellStyleConfiguration, style._configuration) 16 | } 17 | 18 | /// Sets the colors of the swatches in color selection popovers 19 | /// displayed by color wells in this view. 20 | /// 21 | /// Color selection popovers are displayed by color wells that use 22 | /// the ``ColorWellStyle/expanded`` and ``ColorWellStyle/minimal`` 23 | /// styles. This modifier allows you to provide an array of custom 24 | /// colors to display in place of the default colors. 25 | /// 26 | /// ```swift 27 | /// ColorWell(selection: $color) 28 | /// .colorWellSwatchColors([ 29 | /// .red, .orange, .yellow, .green, .blue, .indigo, 30 | /// .purple, .brown, .gray, .white, .black, 31 | /// ]) 32 | /// .colorWellStyle(.expanded) 33 | /// ``` 34 | /// 35 | /// ![Custom swatch colors](custom-swatch-colors) 36 | /// 37 | /// - Parameter colors: An array of colors to use to create the 38 | /// swatches. 39 | @available(macOS 11.0, *) 40 | public func colorWellSwatchColors(_ colors: [Color]) -> some View { 41 | transformEnvironment(\.colorWellSwatchColors) { swatchColors in 42 | swatchColors = colors.map { NSColor($0) } 43 | } 44 | } 45 | 46 | /// Sets an action to perform when the color areas of color wells 47 | /// in this view are pressed. 48 | /// 49 | /// If this modifier is applied, color wells that use either the 50 | /// ``ColorWellStyle/expanded`` or ``ColorWellStyle/minimal`` 51 | /// styles perform the provided action instead of displaying the 52 | /// color selection popover, and modifiers that alter the popover 53 | /// (like ``colorWellSwatchColors(_:)``) have no effect. 54 | /// 55 | /// - Parameter action: An action to perform when the color areas 56 | /// of the color wells are pressed. 57 | public func colorWellSecondaryAction(_ action: @escaping () -> Void) -> some View { 58 | transformEnvironment(\.colorWellSecondaryActionDelegate) { delegate in 59 | delegate = ColorWellSecondaryActionDelegate(action: action) 60 | } 61 | } 62 | 63 | /// Sets the color panel mode for color wells in this view. 64 | /// 65 | /// When a color well that uses this modifier is activated, the 66 | /// system color panel switches to the color panel mode that is 67 | /// passed to the `mode` parameter. 68 | /// 69 | /// - Parameter mode: The color panel mode to apply to the 70 | /// color wells. 71 | public func colorPanelMode(_ mode: M) -> some View { 72 | environment(\.colorPanelModeConfiguration, mode._configuration) 73 | } 74 | } 75 | #endif 76 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Documentation.docc/SwiftUI/ColorWell.md: -------------------------------------------------------------------------------- 1 | # ``ColorWellKit/ColorWell`` 2 | 3 | Color wells provide an interface in your app for users to select custom colors. A color well displays the currently selected color, and provides options for selecting new colors. There are a number of styles to choose from, letting you customize the color well's appearance and behavior. 4 | 5 | You create a color well by providing a title string and a `Binding` to a `Color`: 6 | 7 | ```swift 8 | struct TextFormatter: View { 9 | @Binding var fgColor: Color 10 | @Binding var bgColor: Color 11 | 12 | var body: some View { 13 | HStack { 14 | ColorWell("Foreground", selection: $fgColor) 15 | ColorWell("Background", selection: $bgColor) 16 | } 17 | } 18 | } 19 | ``` 20 | 21 | ![Two color wells, both displayed in the default style](default-style) 22 | 23 | By default, color wells support colors with opacity. To disable opacity support, set the `supportsOpacity` parameter to `false`. 24 | 25 | ```swift 26 | ColorWell("Foreground", selection: $fgColor, supportsOpacity: false) 27 | ``` 28 | 29 | In this mode, the color well does not show controls for adjusting the opacity of the selected color, and removes opacity from colors set programmatically or selected using another method, like drag-and-drop. 30 | 31 | ### Styling color wells 32 | 33 | You can customize a color well's appearance using one of the available color well styles, like ``ColorWellStyle/expanded``, and apply the style with the ``colorWellStyle(_:)`` modifier: 34 | 35 | ```swift 36 | HStack { 37 | ColorWell("Foreground", selection: $fgColor) 38 | ColorWell("Background", selection: $bgColor) 39 | } 40 | .colorWellStyle(.expanded) 41 | ``` 42 | 43 | If you apply the style to a container view, as in the example above, all the color wells in the container use the style: 44 | 45 | ![Two color wells, both displayed in the expanded style](expanded-style) 46 | 47 | ### Modifying the color selection popover 48 | 49 | When you use the ``ColorWellStyle/expanded`` or ``ColorWellStyle/minimal`` color well styles, the color well displays a popover with a grid of selectable color swatches. You can customize the colors that are displayed using the ``colorWellSwatchColors(_:)`` modifier: 50 | 51 | ```swift 52 | ColorWell(selection: $color) 53 | .colorWellSwatchColors([ 54 | .red, .orange, .yellow, .green, .blue, .indigo, 55 | .purple, .brown, .gray, .white, .black, 56 | ]) 57 | .colorWellStyle(.expanded) 58 | ``` 59 | 60 | ![Custom swatch colors](custom-swatch-colors) 61 | 62 | ### Providing a custom secondary action 63 | 64 | As a control, the main action of a color well is always a color selection. By default, a color well's secondary action displays a popover with a grid of selectable color swatches, as described above. You can replace this behavior using the ``colorWellSecondaryAction(_:)`` modifier: 65 | 66 | ```swift 67 | ColorWell(selection: $color) 68 | .colorWellSecondaryAction { 69 | print("color well was pressed") 70 | } 71 | ``` 72 | 73 | The example above will print the text "color well was pressed" to the console instead of showing the popover. 74 | 75 | ## Topics 76 | 77 | ### Creating a color well 78 | 79 | - ``init(selection:supportsOpacity:)-9kcgy`` 80 | - ``init(selection:supportsOpacity:label:)-4cxuv`` 81 | - ``init(_:selection:supportsOpacity:)-3hqzm`` 82 | - ``init(_:selection:supportsOpacity:)-55b4y`` 83 | 84 | ### Creating a Core Graphics color well 85 | 86 | - ``init(selection:supportsOpacity:)-4de3k`` 87 | - ``init(selection:supportsOpacity:label:)-3o6c7`` 88 | - ``init(_:selection:supportsOpacity:)-7metg`` 89 | - ``init(_:selection:supportsOpacity:)-2hp6`` 90 | 91 | ### Modifying color wells 92 | 93 | - ``colorWellStyle(_:)`` 94 | - ``colorWellSwatchColors(_:)`` 95 | - ``colorWellSecondaryAction(_:)`` 96 | - ``colorPanelMode(_:)`` 97 | 98 | ### Getting a color well's content view 99 | 100 | - ``body`` 101 | 102 | ### Supporting Types 103 | 104 | - ``ColorWellStyle`` 105 | - ``ColorPanelMode`` 106 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/SwiftUI/ColorWellStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellStyle.swift 3 | // ColorWellKit 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | 8 | // MARK: - ColorWellStyleConfiguration 9 | 10 | /// Values that configure a color well's style. 11 | @available(macOS 10.15, *) 12 | public struct _ColorWellStyleConfiguration { 13 | /// The underlying style of the color well. 14 | let style: CWColorWell.Style 15 | } 16 | 17 | @available(macOS 10.15, *) 18 | extension _ColorWellStyleConfiguration { 19 | static var `default`: _ColorWellStyleConfiguration { 20 | _ColorWellStyleConfiguration(style: CWColorWell.BackingStorage.defaultStyle) 21 | } 22 | } 23 | 24 | @available(macOS 10.15, *) 25 | extension _ColorWellStyleConfiguration: CustomStringConvertible { 26 | public var description: String { 27 | String(describing: style) 28 | } 29 | } 30 | 31 | // MARK: - ColorWellStyle 32 | 33 | /// A type that specifies the appearance and behavior of a color well. 34 | @available(macOS 10.15, *) 35 | public protocol ColorWellStyle { 36 | /// Values that configure the color well's style. 37 | var _configuration: _ColorWellStyleConfiguration { get } 38 | } 39 | 40 | // MARK: - DefaultColorWellStyle 41 | 42 | /// A color well style that displays the color well's color inside of a 43 | /// rectangular control, and toggles the system color panel when clicked. 44 | /// 45 | /// You can also use ``default`` to construct this style. 46 | @available(macOS 10.15, *) 47 | public struct DefaultColorWellStyle: ColorWellStyle { 48 | public let _configuration = _ColorWellStyleConfiguration(style: .default) 49 | 50 | /// Creates an instance of the default color well style. 51 | public init() { } 52 | } 53 | 54 | @available(macOS 10.15, *) 55 | extension ColorWellStyle where Self == DefaultColorWellStyle { 56 | /// A color well style that displays the color well's color inside of a 57 | /// rectangular control, and toggles the system color panel when clicked. 58 | public static var `default`: DefaultColorWellStyle { 59 | DefaultColorWellStyle() 60 | } 61 | } 62 | 63 | // MARK: - MinimalColorWellStyle 64 | 65 | /// A color well style that displays the color well's color inside of a 66 | /// rectangular control, and shows a popover containing the color well's 67 | /// swatch colors when clicked. 68 | /// 69 | /// You can also use ``minimal`` to construct this style. 70 | @available(macOS 10.15, *) 71 | public struct MinimalColorWellStyle: ColorWellStyle { 72 | public let _configuration = _ColorWellStyleConfiguration(style: .minimal) 73 | 74 | /// Creates an instance of the minimal color well style. 75 | public init() { } 76 | } 77 | 78 | @available(macOS 10.15, *) 79 | extension ColorWellStyle where Self == MinimalColorWellStyle { 80 | /// A color well style that displays the color well's color inside of a 81 | /// rectangular control, and shows a popover containing the color well's 82 | /// swatch colors when clicked. 83 | public static var minimal: MinimalColorWellStyle { 84 | MinimalColorWellStyle() 85 | } 86 | } 87 | 88 | // MARK: - ExpandedColorWellStyle 89 | 90 | /// A color well style that displays the color well's color alongside 91 | /// a dedicated button that toggles the system color panel. 92 | /// 93 | /// Clicking inside the color area displays a popover containing the 94 | /// color well's swatch colors. 95 | /// 96 | /// You can also use ``expanded`` to construct this style. 97 | @available(macOS 10.15, *) 98 | public struct ExpandedColorWellStyle: ColorWellStyle { 99 | public let _configuration = _ColorWellStyleConfiguration(style: .expanded) 100 | 101 | /// Creates an instance of the expanded color well style. 102 | public init() { } 103 | } 104 | 105 | @available(macOS 10.15, *) 106 | extension ColorWellStyle where Self == ExpandedColorWellStyle { 107 | /// A color well style that displays the color well's color alongside 108 | /// a dedicated button that toggles the system color panel. 109 | /// 110 | /// Clicking inside the color area displays a popover containing the 111 | /// color well's swatch colors. 112 | public static var expanded: ExpandedColorWellStyle { 113 | ExpandedColorWellStyle() 114 | } 115 | } 116 | #endif 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ColorWellKit 2 | 3 | [![Continuous Integration][ci-badge]](https://github.com/jordanbaird/ColorWellKit/actions/workflows/test.yml) 4 | [![Release][release-badge]](https://github.com/jordanbaird/ColorWellKit/releases/latest) 5 | [![Swift Versions][versions-badge]](https://swiftpackageindex.com/jordanbaird/ColorWellKit) 6 | [![Docs][docs-badge]](https://swiftpackageindex.com/jordanbaird/ColorWellKit/documentation) 7 | [![License][license-badge]](LICENSE) 8 | 9 | A versatile alternative to `NSColorWell` for Cocoa and `ColorPicker` for SwiftUI. 10 | 11 |
12 | 13 | 14 |
15 | 16 | ColorWellKit is designed to mimic the appearance and behavior of the color well designs introduced in macOS 13 Ventura, ideal for use in apps that are unable to target the latest SDK. While a central goal of ColorWellKit is to maintain a similar look and behave in a similar way to Apple's design, it is not intended to be an exact clone. There are a number of subtle design differences ranging from the way system colors are handled to the size of the drop shadow. In practice, there are very few notable differences: 17 | 18 |
19 | 20 | 21 |
22 | 23 | ## Install 24 | 25 | Add the following dependency to your `Package.swift` file: 26 | 27 | ```swift 28 | .package(url: "https://github.com/jordanbaird/ColorWellKit", from: "1.1.2") 29 | ``` 30 | 31 | ## Usage 32 | 33 | [Read the full documentation here](https://swiftpackageindex.com/jordanbaird/ColorWellKit/documentation) 34 | 35 | ### SwiftUI 36 | 37 | Create a `ColorWell` and add it to your view hierarchy. There are a wide range of initializers and modifiers to choose from, allowing you to set the color well's color, label, and style. 38 | 39 | ```swift 40 | import SwiftUI 41 | import ColorWellKit 42 | 43 | struct ContentView: View { 44 | @Binding var textColor: Color 45 | 46 | var body: some View { 47 | VStack { 48 | ColorWell("Text Color", selection: $textColor) 49 | .colorWellStyle(.expanded) 50 | 51 | MyCustomTextEditor(textColor: $textColor) 52 | } 53 | } 54 | } 55 | ``` 56 | 57 | ### Cocoa 58 | 59 | Create a `CWColorWell` using one of the available initializers, or use an `IBOutlet` to create a connection to a Storyboard or NIB file. Respond to color changes using your preferred design pattern. 60 | 61 | ```swift 62 | import Cocoa 63 | import ColorWellKit 64 | 65 | class ViewController: NSViewController { 66 | @IBOutlet var colorWell: CWColorWell! 67 | @IBOutlet var textEditor: NSTextView! 68 | 69 | override func viewDidLoad() { 70 | colorWell.style = .expanded 71 | if let textColor = textEditor.textColor { 72 | colorWell.color = textColor 73 | } 74 | } 75 | 76 | @IBAction func updateTextColor(sender: CWColorWell) { 77 | textEditor.textColor = sender.color 78 | } 79 | } 80 | ``` 81 | 82 | ## License 83 | 84 | ColorWellKit is available under the [MIT license](LICENSE). 85 | 86 | [ci-badge]: https://img.shields.io/github/actions/workflow/status/jordanbaird/ColorWellKit/test.yml?branch=main&style=flat-square 87 | [release-badge]: https://img.shields.io/github/v/release/jordanbaird/ColorWellKit?style=flat-square 88 | [versions-badge]: https://img.shields.io/badge/dynamic/json?color=F05138&label=Swift&query=%24.message&url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fjordanbaird%2FColorWellKit%2Fbadge%3Ftype%3Dswift-versions&style=flat-square 89 | [docs-badge]: https://img.shields.io/static/v1?label=%20&message=documentation&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAEsSURBVHgB7dntDYIwEAbgV+MAuoEj6AaO4AiO4AayAbqBbuAGjoIbwAbnHT8MMTH9uEJrvCch/FB7vEh7EABjjBMRnXhrKY1GxsNUuFhN45gmBKU783lCDKtBiYeoUoeYI79KE6KEACI6RCkBRFSIkgKI4BClBRBBIUoMILxDlBpASIgjtBL3gR2FaV1jzjyKvg98xqDEw615t3Z87eFbc/IAPkJqljwHvFiA3CxAbhaAdI+cNZTUfWD4edQBOMacog9cEE/z25514twsQG4/H2ABJZ5vG97tEefKc/QJhRR9oIH7AeWbjodchdYcSnEJLRGvg5L6EmJb3g6Ic4eSNbLcLEBuf9HIZKnrl0rtvX8E5zLr8w+o79kVbkiBT/yZxn3Z90lqVTDGOL0AoGWIIaQgyakAAAAASUVORK5CYII=&color=informational&labelColor=gray&style=flat-square 90 | [license-badge]: https://img.shields.io/github/license/jordanbaird/ColorWellKit?style=flat-square 91 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/SwiftUI/ColorPanelMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorPanelMode.swift 3 | // ColorWellKit 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import SwiftUI 8 | 9 | // MARK: - ColorPanelModeConfiguration 10 | 11 | /// Values that configure the system color panel's mode. 12 | @available(macOS 10.15, *) 13 | public struct _ColorPanelModeConfiguration { 14 | /// The underlying color panel mode. 15 | let mode: NSColorPanel.Mode 16 | } 17 | 18 | // MARK: - ColorPanelMode 19 | 20 | /// A type that specifies a mode for the system color panel. 21 | @available(macOS 10.15, *) 22 | public protocol ColorPanelMode { 23 | /// Values that configure the system color panel's mode. 24 | var _configuration: _ColorPanelModeConfiguration { get } 25 | } 26 | 27 | // MARK: - GrayscaleColorPanelMode 28 | 29 | /// The grayscale color panel mode. 30 | @available(macOS 10.15, *) 31 | public struct GrayscaleColorPanelMode: ColorPanelMode { 32 | public let _configuration = _ColorPanelModeConfiguration(mode: .gray) 33 | 34 | /// Creates an instance of the grayscale color panel mode. 35 | public init() { } 36 | } 37 | 38 | @available(macOS 10.15, *) 39 | extension ColorPanelMode where Self == GrayscaleColorPanelMode { 40 | /// The grayscale color panel mode. 41 | public static var gray: GrayscaleColorPanelMode { 42 | GrayscaleColorPanelMode() 43 | } 44 | } 45 | 46 | // MARK: - RGBColorPanelMode 47 | 48 | /// The red-green-blue color panel mode. 49 | @available(macOS 10.15, *) 50 | public struct RGBColorPanelMode: ColorPanelMode { 51 | public let _configuration = _ColorPanelModeConfiguration(mode: .RGB) 52 | 53 | /// Creates an instance of the red-green-blue color panel mode. 54 | public init() { } 55 | } 56 | 57 | @available(macOS 10.15, *) 58 | extension ColorPanelMode where Self == RGBColorPanelMode { 59 | /// The red-green-blue color panel mode. 60 | public static var rgb: RGBColorPanelMode { 61 | RGBColorPanelMode() 62 | } 63 | } 64 | 65 | // MARK: - CMYKColorPanelMode 66 | 67 | /// The cyan-magenta-yellow-black color panel mode. 68 | @available(macOS 10.15, *) 69 | public struct CMYKColorPanelMode: ColorPanelMode { 70 | public let _configuration = _ColorPanelModeConfiguration(mode: .CMYK) 71 | 72 | /// Creates an instance of the cyan-magenta-yellow-black color panel mode. 73 | public init() { } 74 | } 75 | 76 | @available(macOS 10.15, *) 77 | extension ColorPanelMode where Self == CMYKColorPanelMode { 78 | /// The cyan-magenta-yellow-black color panel mode. 79 | public static var cmyk: CMYKColorPanelMode { 80 | CMYKColorPanelMode() 81 | } 82 | } 83 | 84 | // MARK: - HSBColorPanelMode 85 | 86 | /// The hue-saturation-brightness color panel mode. 87 | @available(macOS 10.15, *) 88 | public struct HSBColorPanelMode: ColorPanelMode { 89 | public let _configuration = _ColorPanelModeConfiguration(mode: .HSB) 90 | 91 | /// Creates an instance of the hue-saturation-brightness color panel mode. 92 | public init() { } 93 | } 94 | 95 | @available(macOS 10.15, *) 96 | extension ColorPanelMode where Self == HSBColorPanelMode { 97 | /// The hue-saturation-brightness color panel mode. 98 | public static var hsb: HSBColorPanelMode { 99 | HSBColorPanelMode() 100 | } 101 | } 102 | 103 | // MARK: - CustomPaletteColorPanelMode 104 | 105 | /// The custom palette color panel mode. 106 | @available(macOS 10.15, *) 107 | public struct CustomPaletteColorPanelMode: ColorPanelMode { 108 | public let _configuration = _ColorPanelModeConfiguration(mode: .customPalette) 109 | 110 | /// Creates an instance of the custom palette color panel mode. 111 | public init() { } 112 | } 113 | 114 | @available(macOS 10.15, *) 115 | extension ColorPanelMode where Self == CustomPaletteColorPanelMode { 116 | /// The custom palette color panel mode. 117 | public static var customPalette: CustomPaletteColorPanelMode { 118 | CustomPaletteColorPanelMode() 119 | } 120 | } 121 | 122 | // MARK: - ColorListColorPanelMode 123 | 124 | /// The color list color panel mode. 125 | @available(macOS 10.15, *) 126 | public struct ColorListColorPanelMode: ColorPanelMode { 127 | public let _configuration = _ColorPanelModeConfiguration(mode: .colorList) 128 | 129 | /// Creates an instance of the color list color panel mode. 130 | public init() { } 131 | } 132 | 133 | @available(macOS 10.15, *) 134 | extension ColorPanelMode where Self == ColorListColorPanelMode { 135 | /// The color list color panel mode. 136 | public static var colorList: ColorListColorPanelMode { 137 | ColorListColorPanelMode() 138 | } 139 | } 140 | 141 | // MARK: - ColorWheelColorPanelMode 142 | 143 | /// The color wheel color panel mode. 144 | @available(macOS 10.15, *) 145 | public struct ColorWheelColorPanelMode: ColorPanelMode { 146 | public let _configuration = _ColorPanelModeConfiguration(mode: .wheel) 147 | 148 | /// Creates an instance of the color wheel color panel mode. 149 | public init() { } 150 | } 151 | 152 | @available(macOS 10.15, *) 153 | extension ColorPanelMode where Self == ColorWheelColorPanelMode { 154 | /// The color wheel color panel mode. 155 | public static var wheel: ColorWheelColorPanelMode { 156 | ColorWheelColorPanelMode() 157 | } 158 | } 159 | 160 | // MARK: - CrayonPickerColorPanelMode 161 | 162 | /// The crayon picker color panel mode. 163 | @available(macOS 10.15, *) 164 | public struct CrayonPickerColorPanelMode: ColorPanelMode { 165 | public let _configuration = _ColorPanelModeConfiguration(mode: .crayon) 166 | 167 | /// Creates an instance of the crayon picker color panel mode. 168 | public init() { } 169 | } 170 | 171 | @available(macOS 10.15, *) 172 | extension ColorPanelMode where Self == CrayonPickerColorPanelMode { 173 | /// The crayon picker color panel mode. 174 | public static var crayon: CrayonPickerColorPanelMode { 175 | CrayonPickerColorPanelMode() 176 | } 177 | } 178 | #endif 179 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/SwiftUI/ColorWell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWell.swift 3 | // ColorWellKit 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import SwiftUI 8 | 9 | /// A SwiftUI view that displays a user-selectable color value. 10 | @available(macOS 10.15, *) 11 | public struct ColorWell: View { 12 | @Binding private var selection: NSColor 13 | 14 | private let supportsOpacity: Bool 15 | 16 | private let label: Label? 17 | 18 | private var representable: some View { 19 | ColorWellRepresentable(selection: $selection, supportsOpacity: supportsOpacity) 20 | .alignmentGuide(.firstTextBaseline) { context in 21 | context[VerticalAlignment.center] 22 | } 23 | .fixedSize() 24 | } 25 | 26 | private var alignedLabel: (some View)? { 27 | label?.alignmentGuide(.firstTextBaseline) { context in 28 | context[VerticalAlignment.center] 29 | } 30 | } 31 | 32 | /// The content view of the color well. 33 | public var body: some View { 34 | if let alignedLabel { 35 | if #available(macOS 13.0, *) { 36 | LabeledContent( 37 | content: { representable }, 38 | label: { alignedLabel } 39 | ) 40 | } else { 41 | Backports.LabeledContent( 42 | content: { representable }, 43 | label: { alignedLabel } 44 | ) 45 | } 46 | } else { 47 | representable 48 | } 49 | } 50 | 51 | /// A base initializer for others to delegate to. 52 | private init(selection: Binding, supportsOpacity: Bool, label: Label?) { 53 | self._selection = selection 54 | self.supportsOpacity = supportsOpacity 55 | self.label = label 56 | } 57 | } 58 | 59 | // MARK: ColorWell where Label: View 60 | @available(macOS 10.15, *) 61 | extension ColorWell { 62 | /// Creates a color well with a binding to a color value, with the provided 63 | /// view being used as the color well's label. 64 | /// 65 | /// - Parameters: 66 | /// - selection: A binding to the color well's color. 67 | /// - supportsOpacity: A Boolean value that indicates whether the color well 68 | /// allows adjusting the selected color's opacity; the default is `true`. 69 | /// - label: A view that describes the purpose of the color well. 70 | @available(macOS 11.0, *) 71 | public init(selection: Binding, supportsOpacity: Bool = true, @ViewBuilder label: () -> Label) { 72 | self.init(selection: selection.nsColor, supportsOpacity: supportsOpacity, label: label()) 73 | } 74 | 75 | /// Creates a color well with a binding to a color value, with the provided 76 | /// view being used as the color well's label. 77 | /// 78 | /// - Parameters: 79 | /// - selection: A binding to the color well's color. 80 | /// - supportsOpacity: A Boolean value that indicates whether the color well 81 | /// allows adjusting the selected color's opacity; the default is `true`. 82 | /// - label: A view that describes the purpose of the color well. 83 | public init(selection: Binding, supportsOpacity: Bool = true, @ViewBuilder label: () -> Label) { 84 | self.init(selection: selection.nsColor, supportsOpacity: supportsOpacity, label: label()) 85 | } 86 | } 87 | 88 | // MARK: ColorWell where Label == Never 89 | @available(macOS 10.15, *) 90 | extension ColorWell where Label == Never { 91 | /// Creates a color well with a binding to a color value. 92 | /// 93 | /// - Parameters: 94 | /// - selection: A binding to the color well's color. 95 | /// - supportsOpacity: A Boolean value that indicates whether the color well 96 | /// allows adjusting the selected color's opacity; the default is `true`. 97 | @available(macOS 11.0, *) 98 | public init(selection: Binding, supportsOpacity: Bool = true) { 99 | self.init(selection: selection.nsColor, supportsOpacity: supportsOpacity, label: nil) 100 | } 101 | 102 | /// Creates a color well with a binding to a color value. 103 | /// 104 | /// - Parameters: 105 | /// - selection: A binding to the color well's color. 106 | /// - supportsOpacity: A Boolean value that indicates whether the color well 107 | /// allows adjusting the selected color's opacity; the default is `true`. 108 | public init(selection: Binding, supportsOpacity: Bool = true) { 109 | self.init(selection: selection.nsColor, supportsOpacity: supportsOpacity, label: nil) 110 | } 111 | } 112 | 113 | // MARK: ColorWell where Label == Text 114 | @available(macOS 10.15, *) 115 | extension ColorWell where Label == Text { 116 | 117 | // MARK: Generate Label From StringProtocol 118 | 119 | /// Creates a color well with a binding to a color value, that generates its 120 | /// label from a string. 121 | /// 122 | /// - Parameters: 123 | /// - title: A string that describes the purpose of the color well. 124 | /// - selection: A binding to the color well's color. 125 | /// - supportsOpacity: A Boolean value that indicates whether the color well 126 | /// allows adjusting the selected color's opacity; the default is `true`. 127 | @available(macOS 11.0, *) 128 | public init(_ title: S, selection: Binding, supportsOpacity: Bool = true) { 129 | self.init(selection: selection.nsColor, supportsOpacity: supportsOpacity, label: Text(title)) 130 | } 131 | 132 | /// Creates a color well with a binding to a color value, that generates its 133 | /// label from a string. 134 | /// 135 | /// - Parameters: 136 | /// - title: A string that describes the purpose of the color well. 137 | /// - selection: A binding to the color well's color. 138 | /// - supportsOpacity: A Boolean value that indicates whether the color well 139 | /// allows adjusting the selected color's opacity; the default is `true`. 140 | public init(_ title: S, selection: Binding, supportsOpacity: Bool = true) { 141 | self.init(selection: selection.nsColor, supportsOpacity: supportsOpacity, label: Text(title)) 142 | } 143 | 144 | // MARK: Generate Label From LocalizedStringKey 145 | 146 | /// Creates a color well with a binding to a color value, that generates its 147 | /// label from a localized string key. 148 | /// 149 | /// - Parameters: 150 | /// - titleKey: The key for the localized title of the color well. 151 | /// - selection: A binding to the color well's color. 152 | /// - supportsOpacity: A Boolean value that indicates whether the color well 153 | /// allows adjusting the selected color's opacity; the default is `true`. 154 | @available(macOS 11.0, *) 155 | public init(_ titleKey: LocalizedStringKey, selection: Binding, supportsOpacity: Bool = true) { 156 | self.init(selection: selection.nsColor, supportsOpacity: supportsOpacity, label: Text(titleKey)) 157 | } 158 | 159 | /// Creates a color well with a binding to a color value, that generates its 160 | /// label from a localized string key. 161 | /// 162 | /// - Parameters: 163 | /// - titleKey: The key for the localized title of the color well. 164 | /// - selection: A binding to the color well's color. 165 | /// - supportsOpacity: A Boolean value that indicates whether the color well 166 | /// allows adjusting the selected color's opacity; the default is `true`. 167 | public init(_ titleKey: LocalizedStringKey, selection: Binding, supportsOpacity: Bool = true) { 168 | self.init(selection: selection.nsColor, supportsOpacity: supportsOpacity, label: Text(titleKey)) 169 | } 170 | } 171 | #endif 172 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/Cocoa/CWColorWellLayoutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CWColorWellLayoutView.swift 3 | // ColorWellKit 4 | // 5 | 6 | import AppKit 7 | 8 | private protocol LayoutAnchorProtocol: Hashable { } 9 | 10 | private extension LayoutAnchorProtocol { 11 | var key: LayoutAnchorKey { 12 | LayoutAnchorKey(self) 13 | } 14 | } 15 | 16 | private struct LayoutAnchorKey: Hashable { 17 | private let rawValue: Int 18 | 19 | init(_ base: Base) { 20 | self.rawValue = base.hashValue 21 | } 22 | } 23 | 24 | extension NSLayoutAnchor: LayoutAnchorProtocol { } 25 | 26 | /// A grid view that displays color well segments side by side. 27 | class CWColorWellLayoutView: NSGridView { 28 | 29 | // MARK: Properties 30 | 31 | @objc dynamic // @objc dynamic to enable kvo 32 | private weak var colorWell: CWColorWell? 33 | 34 | @objc dynamic // @objc dynamic to enable kvo 35 | private var widthConstant: CGFloat = 0 36 | 37 | private var row: NSGridRow? 38 | 39 | private let bezelGradient: NSGradient 40 | 41 | private(set) var segments = [CWColorWellSegment]() 42 | 43 | private var styleObservation: NSKeyValueObservation? 44 | 45 | private var widthConstantObservation: NSKeyValueObservation? 46 | 47 | private var superviewConstraints = [LayoutAnchorKey: NSLayoutConstraint]() { 48 | didSet { 49 | for constraint in oldValue.values { 50 | constraint.isActive = false 51 | } 52 | for constraint in superviewConstraints.values { 53 | constraint.isActive = true 54 | } 55 | } 56 | } 57 | 58 | // MARK: Initializers 59 | 60 | init(colorWell: CWColorWell) { 61 | self.bezelGradient = NSGradient(colors: [ 62 | NSColor.clear, 63 | NSColor.clear, 64 | NSColor.clear, 65 | NSColor(white: 1, alpha: 0.125), 66 | ])! // swiftlint:disable:this force_unwrapping 67 | super.init(frame: .zero) 68 | self.translatesAutoresizingMaskIntoConstraints = false 69 | self.wantsLayer = true 70 | self.columnSpacing = 0 71 | self.xPlacement = .fill 72 | self.yPlacement = .fill 73 | self.colorWell = colorWell 74 | self.styleObservation = observe(\.colorWell?.style, options: .initial) { layoutView, _ in 75 | layoutView.update() 76 | } 77 | } 78 | 79 | @available(*, unavailable) 80 | required init?(coder: NSCoder) { 81 | fatalError("init(coder:) has not been implemented") 82 | } 83 | 84 | // MARK: Methods 85 | 86 | /// Marks the layout view's segments as needing to be redrawn 87 | /// before being displayed. 88 | func setSegmentsNeedDisplay(_ segmentsNeedDisplay: Bool) { 89 | for segment in segments { 90 | segment.needsDisplay = segmentsNeedDisplay 91 | } 92 | } 93 | 94 | /// Resets the layout view to a default state. 95 | func resetLayoutView() { 96 | defer { 97 | assert(segments.isEmpty, "segments should be empty after layout view reset.") 98 | assert(numberOfRows == 0, "numberOfRows should be 0 after layout view reset.") 99 | } 100 | while let segment = segments.popLast() { 101 | segment.removeFromSuperview() 102 | } 103 | if let row { 104 | removeRow(at: index(of: row)) 105 | } 106 | row = nil 107 | } 108 | 109 | /// Updates the properties of the layout view's shadow according 110 | /// to the color well's current style. 111 | func updateShadowProperties() { 112 | guard 113 | let colorWell, 114 | let layer 115 | else { 116 | return 117 | } 118 | switch colorWell.style { 119 | case .default: 120 | layer.shadowRadius = 0.5 121 | layer.shadowOpacity = 0.3 122 | layer.shadowOffset = NSSize(width: 0, height: -0.25) 123 | case .minimal: 124 | layer.shadowRadius = 0 125 | layer.shadowOpacity = 0 126 | layer.shadowOffset = NSSize(width: 0, height: 0) 127 | case .expanded: 128 | layer.shadowRadius = 0.4 129 | layer.shadowOpacity = 0.3 130 | layer.shadowOffset = NSSize(width: 0, height: -0.2) 131 | } 132 | } 133 | 134 | /// Updates the layout view according to the color well's 135 | /// current style. 136 | func update() { 137 | guard let colorWell else { 138 | return 139 | } 140 | resetLayoutView() 141 | switch colorWell.style { 142 | case .default: 143 | segments.append(CWBorderedSwatchSegment(colorWell: colorWell)) 144 | widthConstant = 0 145 | case .minimal: 146 | segments.append(CWSinglePullDownSwatchSegment(colorWell: colorWell)) 147 | widthConstant = 0 148 | case .expanded: 149 | segments.append(CWPartialPullDownSwatchSegment(colorWell: colorWell)) 150 | segments.append(CWToggleSegment(colorWell: colorWell)) 151 | widthConstant = -1 152 | } 153 | row = addRow(with: segments) 154 | updateShadowProperties() 155 | } 156 | 157 | /// Draws a bezel for the layout view in the given rectangle. 158 | func drawBezel() { 159 | guard let colorWell else { 160 | return 161 | } 162 | 163 | let lineWidth = 0.75 164 | let bezelPath: NSBezierPath 165 | 166 | switch colorWell.style { 167 | case .expanded: 168 | let widthConstant = CWToggleSegment.widthConstant 169 | bezelPath = Path.segmentPath( 170 | rect: NSRect( 171 | x: bounds.maxX - widthConstant, 172 | y: bounds.minY + lineWidth / 2, 173 | width: widthConstant - lineWidth / 2, 174 | height: bounds.height - lineWidth 175 | ), 176 | controlSize: colorWell.controlSize, 177 | segmentType: CWToggleSegment.self, 178 | shouldClose: false 179 | ) 180 | .stroked(lineWidth: lineWidth) 181 | .nsBezierPath() 182 | case .default, .minimal: 183 | bezelPath = Path.fullColorWellPath( 184 | rect: bounds.insetBy(dx: lineWidth / 2, dy: lineWidth / 2), 185 | controlSize: colorWell.controlSize 186 | ) 187 | .stroked(lineWidth: lineWidth) 188 | .nsBezierPath() 189 | } 190 | 191 | bezelGradient.draw(in: bezelPath, angle: 90) 192 | } 193 | 194 | override func viewWillDraw() { 195 | super.viewWillDraw() 196 | updateShadowProperties() 197 | } 198 | 199 | override func draw(_ dirtyRect: NSRect) { 200 | super.draw(dirtyRect) 201 | drawBezel() 202 | } 203 | 204 | override func viewDidMoveToSuperview() { 205 | if let superview { 206 | superviewConstraints = [ 207 | widthAnchor.key: widthAnchor.constraint(equalTo: superview.widthAnchor), 208 | heightAnchor.key: heightAnchor.constraint(equalTo: superview.heightAnchor), 209 | leadingAnchor.key: leadingAnchor.constraint(equalTo: superview.leadingAnchor), 210 | bottomAnchor.key: bottomAnchor.constraint(equalTo: superview.bottomAnchor), 211 | ] 212 | } else { 213 | superviewConstraints.removeAll() 214 | } 215 | 216 | widthConstantObservation = observe( 217 | \.widthConstant, 218 | options: [.initial, .new] 219 | ) { [weak self] _, change in 220 | guard 221 | let self, 222 | let newValue = change.newValue 223 | else { 224 | return 225 | } 226 | superviewConstraints[widthAnchor.key]?.constant = newValue 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/SwiftUI/ColorWellRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellRepresentable.swift 3 | // ColorWellKit 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import SwiftUI 8 | 9 | @available(macOS 10.15, *) 10 | struct ColorWellRepresentable: NSViewRepresentable { 11 | final class BridgedColorWell: CWColorWell { 12 | var mouseMonitor: LocalEventMonitor? 13 | 14 | var supportsOpacity: Bool = true { 15 | didSet { 16 | // remove opacity from the current color if needed 17 | updateColor(color, options: []) 18 | } 19 | } 20 | 21 | func segment(at point: NSPoint) -> CWColorWellSegment? { 22 | layoutView.segments.first { segment in 23 | segment.frameConvertedToWindow.contains(point) 24 | } 25 | } 26 | 27 | override func updateColor(_ newColor: NSColor?, options: ColorUpdateOptions) { 28 | guard let newColor else { 29 | // the current implementation handles nil values by setting to 30 | // black (same as NSColorWell); call super before returning to 31 | // ensure consistent behavior 32 | super.updateColor(nil, options: options) 33 | return 34 | } 35 | if supportsOpacity || newColor.alphaComponent == 1 { 36 | // color well either supports opacity, or the new color is 37 | // already opaque; pass through to super 38 | super.updateColor(newColor, options: options) 39 | } else { 40 | let opaqueColor = newColor.withAlphaComponent(1) 41 | 42 | // wish we didn't need this, but it prevents some unnecessary 43 | // state modifications 44 | // TODO: Investigate... 45 | guard !opaqueColor.resembles(color, tolerance: 0) else { 46 | return 47 | } 48 | 49 | super.updateColor(opaqueColor, options: options) 50 | } 51 | } 52 | 53 | override func computeIntrinsicContentSize(for controlSize: ControlSize) -> NSSize { 54 | var size = BackingStorage.defaultSize 55 | switch backingStorage.style { 56 | case .default: 57 | switch controlSize { 58 | case .large: 59 | size.width += 17 60 | size.height += 5 61 | case .regular: 62 | size.width += 6 63 | case .small: 64 | size.width -= 5 65 | size.height -= 7 66 | case .mini: 67 | size.width -= 9 68 | size.height -= 9 69 | @unknown default: 70 | break 71 | } 72 | case .minimal: 73 | switch controlSize { 74 | case .large: 75 | size.width += 17 76 | size.height += 5 77 | case .regular: 78 | size.width += 3 79 | case .small: 80 | size.width -= 5 81 | size.height -= 7 82 | case .mini: 83 | size.width -= 9 84 | size.height -= 9 85 | @unknown default: 86 | break 87 | } 88 | case .expanded: 89 | size.width += CWToggleSegment.widthConstant 90 | switch controlSize { 91 | case .large: 92 | size.width += 6 93 | size.height += 5 94 | case .regular: 95 | break // no change 96 | case .small: 97 | size.width -= 8 98 | size.height -= 7 99 | case .mini: 100 | size.width -= 9 101 | size.height -= 9 102 | @unknown default: 103 | break 104 | } 105 | } 106 | return size 107 | } 108 | } 109 | 110 | final class BridgedColorWellDelegate: CWColorWellDelegate { 111 | let representable: ColorWellRepresentable 112 | 113 | init(representable: ColorWellRepresentable) { 114 | self.representable = representable 115 | } 116 | 117 | func colorWellDidChangeColor(_ colorWell: CWColorWell) { 118 | representable.selection = colorWell.color 119 | } 120 | 121 | func colorWellDidActivate(_ colorWell: CWColorWell) { 122 | if NSColorPanel.shared.isMainAttachedObject(colorWell) { 123 | NSColorPanel.shared.showsAlpha = representable.supportsOpacity 124 | } 125 | } 126 | } 127 | 128 | @Binding var selection: NSColor 129 | 130 | let supportsOpacity: Bool 131 | 132 | func makeNSView(context: Context) -> BridgedColorWell { 133 | let colorWell = BridgedColorWell(color: selection) 134 | 135 | colorWell.supportsOpacity = supportsOpacity 136 | colorWell.delegate = context.coordinator 137 | 138 | // certain SwiftUI views (i.e. group-styled forms) prevent the color well 139 | // from receiving mouse events; workaround for now is to install a local 140 | // event monitor and pass the event to the segment at the event's location 141 | let mouseMonitor = LocalEventMonitor( 142 | mask: [.leftMouseDown, .leftMouseUp, .leftMouseDragged] 143 | ) { [weak colorWell] event in 144 | let locationInWindow = event.locationInWindow 145 | guard 146 | let colorWell, 147 | event.window === colorWell.window, 148 | colorWell.frameConvertedToWindow.contains(locationInWindow), 149 | let segment = colorWell.segment(at: locationInWindow) 150 | else { 151 | return event 152 | } 153 | switch event.type { 154 | case .leftMouseDown: 155 | segment.mouseDown(with: event) 156 | return nil 157 | case .leftMouseUp: 158 | segment.mouseUp(with: event) 159 | return nil 160 | case .leftMouseDragged: 161 | segment.mouseDragged(with: event) 162 | return nil 163 | default: 164 | return event 165 | } 166 | } 167 | 168 | mouseMonitor.start() 169 | colorWell.mouseMonitor = mouseMonitor 170 | 171 | return colorWell 172 | } 173 | 174 | func updateNSView(_ colorWell: BridgedColorWell, context: Context) { 175 | if colorWell.supportsOpacity != supportsOpacity { 176 | colorWell.supportsOpacity = supportsOpacity 177 | } 178 | if colorWell.color != selection { 179 | colorWell.color = selection 180 | } 181 | if colorWell.style != context.environment.colorWellStyleConfiguration.style { 182 | colorWell.style = context.environment.colorWellStyleConfiguration.style 183 | } 184 | if 185 | let swatchColors = context.environment.colorWellSwatchColors, 186 | colorWell.swatchColors != swatchColors 187 | { 188 | colorWell.swatchColors = swatchColors 189 | } 190 | if colorWell.colorPanelMode != context.environment.colorPanelModeConfiguration?.mode { 191 | colorWell.colorPanelMode = context.environment.colorPanelModeConfiguration?.mode 192 | } 193 | if let secondaryActionDelegate = context.environment.colorWellSecondaryActionDelegate { 194 | colorWell.secondaryAction = #selector(secondaryActionDelegate.performAction) 195 | colorWell.secondaryTarget = secondaryActionDelegate 196 | } else { 197 | colorWell.secondaryAction = nil 198 | colorWell.secondaryTarget = nil 199 | } 200 | } 201 | 202 | func makeCoordinator() -> BridgedColorWellDelegate { 203 | BridgedColorWellDelegate(representable: self) 204 | } 205 | } 206 | #endif 207 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Utilities/ColorHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorHelpers.swift 3 | // ColorWellKit 4 | // 5 | 6 | import AppKit 7 | 8 | // MARK: - ColorInfo 9 | 10 | /// Container for color type and component information. 11 | struct ColorInfo: CustomStringConvertible { 12 | /// Color type information. 13 | private enum ColorType: CustomStringConvertible { 14 | case componentBased(components: ColorComponents) 15 | case pattern(image: NSImage) 16 | case catalog(name: String) 17 | case unknown(color: NSColor) 18 | case deviceN 19 | case indexed 20 | case lab 21 | 22 | var description: String { 23 | switch self { 24 | case .componentBased(let components): 25 | switch components { 26 | case .rgb: "rgb" 27 | case .cmyk: "cmyk" 28 | case .grayscale: "grayscale" 29 | case .other: "component-based color" 30 | case .invalid: "invalid" 31 | } 32 | case .pattern: "pattern image" 33 | case .catalog: "catalog color" 34 | case .unknown: "unknown color space" 35 | case .deviceN: "deviceN" 36 | case .indexed: "indexed" 37 | case .lab: "L*a*b*" 38 | } 39 | } 40 | } 41 | 42 | /// Color component information. 43 | private enum ColorComponents { 44 | case rgb(red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) 45 | case cmyk(cyan: CGFloat, magenta: CGFloat, yellow: CGFloat, black: CGFloat, alpha: CGFloat) 46 | case grayscale(white: CGFloat, alpha: CGFloat) 47 | case other(components: [CGFloat]) 48 | case invalid 49 | 50 | init(color: NSColor) { 51 | switch color.colorSpace.colorSpaceModel { 52 | case _ where color.type != .componentBased: 53 | cw_log( 54 | "Attempted to get the components for a non component-based color", 55 | category: .components, 56 | type: .error 57 | ) 58 | self = .invalid 59 | case .rgb: 60 | self = .rgb( 61 | red: color.redComponent, 62 | green: color.greenComponent, 63 | blue: color.blueComponent, 64 | alpha: color.alphaComponent 65 | ) 66 | case .cmyk: 67 | self = .cmyk( 68 | cyan: color.cyanComponent, 69 | magenta: color.magentaComponent, 70 | yellow: color.yellowComponent, 71 | black: color.blackComponent, 72 | alpha: color.alphaComponent 73 | ) 74 | case .gray: 75 | self = .grayscale( 76 | white: color.whiteComponent, 77 | alpha: color.alphaComponent 78 | ) 79 | default: 80 | var components = [CGFloat](repeating: 0, count: color.numberOfComponents) 81 | color.getComponents(&components) 82 | self = .other(components: components) 83 | } 84 | } 85 | } 86 | 87 | private static let formatter: NumberFormatter = { 88 | let formatter = NumberFormatter() 89 | formatter.minimumIntegerDigits = 1 90 | formatter.minimumFractionDigits = 0 91 | formatter.maximumFractionDigits = 6 92 | return formatter 93 | }() 94 | 95 | private let type: ColorType 96 | 97 | /// The raw components extracted from this instance. 98 | var extractedComponents: [Any] { 99 | switch type { 100 | case .componentBased(let components): 101 | switch components { 102 | case .rgb(let red, let green, let blue, let alpha): 103 | [red, green, blue, alpha] 104 | case .cmyk(let cyan, let magenta, let yellow, let black, let alpha): 105 | [cyan, magenta, yellow, black, alpha] 106 | case .grayscale(let white, let alpha): 107 | [white, alpha] 108 | case .other(let components): 109 | components 110 | case .invalid: 111 | [] 112 | } 113 | case .pattern(let image): 114 | [image] 115 | case .catalog(let name): 116 | [name] 117 | case .unknown(let color): 118 | [String(describing: color)] 119 | default: 120 | [] 121 | } 122 | } 123 | 124 | /// String representations of the components extracted from this instance. 125 | var extractedComponentStrings: [String] { 126 | extractedComponents.compactMap { component in 127 | if let component = component as? NSNumber { 128 | Self.formatter.string(from: component) 129 | } else { 130 | String(describing: component) 131 | } 132 | } 133 | } 134 | 135 | var description: String { 136 | "\(type) \(extractedComponentStrings.joined(separator: " "))" 137 | } 138 | 139 | /// Creates an instance from the specified color. 140 | init(color: NSColor) { 141 | self.type = switch color.type { 142 | case .componentBased: .componentBased(components: ColorComponents(color: color)) 143 | case .pattern: .pattern(image: color.patternImage) 144 | case .catalog: .catalog(name: color.localizedColorNameComponent) 145 | @unknown default: .unknown(color: color) 146 | } 147 | } 148 | } 149 | 150 | // MARK: - ColorScheme 151 | 152 | /// A value corresponding to a light or dark appearance. 153 | enum ColorScheme { 154 | /// A color scheme that indicates a light appearance. 155 | case light 156 | /// A color scheme that indicates a dark appearance. 157 | case dark 158 | 159 | /// The names of the light appearances used by the system. 160 | private static let systemLightAppearanceNames: Set = { 161 | var result: Set = [ 162 | .aqua, 163 | .vibrantLight, 164 | ] 165 | if #available(macOS 10.14, *) { 166 | result.formUnion([ 167 | .accessibilityHighContrastAqua, 168 | .accessibilityHighContrastVibrantLight, 169 | ]) 170 | } 171 | return result 172 | }() 173 | 174 | /// The names of the dark appearances used by the system. 175 | private static let systemDarkAppearanceNames: Set = { 176 | var result: Set = [ 177 | .vibrantDark, 178 | ] 179 | if #available(macOS 10.14, *) { 180 | result.formUnion([ 181 | .darkAqua, 182 | .accessibilityHighContrastDarkAqua, 183 | .accessibilityHighContrastVibrantDark, 184 | ]) 185 | } 186 | return result 187 | }() 188 | 189 | /// Returns the color scheme that exactly matches the given appearance, 190 | /// or `nil` if the color scheme cannot be determined. 191 | private static func exactMatch(for appearance: NSAppearance) -> ColorScheme? { 192 | let name = appearance.name 193 | if systemDarkAppearanceNames.contains(name) { 194 | return .dark 195 | } 196 | if systemLightAppearanceNames.contains(name) { 197 | return .light 198 | } 199 | return nil 200 | } 201 | 202 | /// Returns the color scheme that best matches the given appearance, 203 | /// or `nil` if the color scheme cannot be determined. 204 | private static func bestMatch(for appearance: NSAppearance) -> ColorScheme? { 205 | let lowercased = appearance.name.rawValue.lowercased() 206 | if lowercased.contains("dark") { 207 | return .dark 208 | } 209 | if lowercased.contains("light") || lowercased.contains("aqua") { 210 | return .light 211 | } 212 | return nil 213 | } 214 | 215 | /// Returns the color scheme of the given appearance. 216 | /// 217 | /// If a color scheme cannot be found that matches the given appearance, 218 | /// the `light` color scheme is returned. 219 | private static func colorScheme(for appearance: NSAppearance) -> ColorScheme { 220 | if let match = exactMatch(for: appearance) { 221 | return match 222 | } 223 | if let match = bestMatch(for: appearance) { 224 | return match 225 | } 226 | return .light 227 | } 228 | 229 | /// Returns the color scheme of the current appearance. 230 | /// 231 | /// If a color scheme cannot be found that matches the given appearance, 232 | /// the `light` color scheme is returned. 233 | static var current: ColorScheme { 234 | if #available(macOS 11.0, *) { 235 | return colorScheme(for: .currentDrawing()) 236 | } else { 237 | return colorScheme(for: .current) 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/Cocoa/CWColorWellBaseControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CWColorWellBaseControl.swift 3 | // ColorWellKit 4 | // 5 | 6 | import AppKit 7 | 8 | /// A base control that contains some default functionality for use in the 9 | /// main ``CWColorWell`` class. 10 | /// 11 | /// The public ``CWColorWell`` class inherits from this class. The underscore 12 | /// in front of the name of this class indicates that it is a private API, 13 | /// and shouldn't be used. This class exists to enable public properties and 14 | /// methods to be overridden without polluting the package's documentation, 15 | /// and will probably continue to exist until a better solution is found. 16 | public class _CWColorWellBaseControl: NSControl { 17 | 18 | // MARK: Types 19 | 20 | struct BackingStorage { 21 | static let defaultColor = NSColor(red: 1, green: 1, blue: 1, alpha: 1) 22 | 23 | static let defaultStyle = CWColorWell.Style.default 24 | 25 | static let defaultSize = NSSize(width: 38, height: 24) 26 | 27 | var color = Self.defaultColor 28 | 29 | var style = Self.defaultStyle 30 | 31 | var isEnabled = true 32 | 33 | var cell: NSCell? 34 | } 35 | 36 | /// Options that describe the side effects that should occur when a 37 | /// color well updates its color. 38 | struct ColorUpdateOptions: OptionSet { 39 | let rawValue: UInt 40 | 41 | /// The color well will send a message to its delegate before and 42 | /// after the change. 43 | static let informDelegate = ColorUpdateOptions(rawValue: 1 << 0) 44 | 45 | /// The color well will send a notification before and after the 46 | /// change to any objects observing its color value. 47 | static let informObservers = ColorUpdateOptions(rawValue: 1 << 1) 48 | 49 | /// The color well will send its action message to its target. 50 | static let sendAction = ColorUpdateOptions(rawValue: 1 << 2) 51 | } 52 | 53 | // MARK: Static Properties 54 | 55 | private static let colorPanelObservations: [NSKeyValueObservation] = [ 56 | NSColorPanel.shared.observe(\.color) { colorPanel, _ in 57 | for case let colorWell as CWColorWell in colorPanel.attachedObjects { 58 | colorWell.updateColor(colorPanel.color, options: [ 59 | .informDelegate, 60 | .informObservers, 61 | .sendAction, 62 | ]) 63 | } 64 | }, 65 | NSColorPanel.shared.observe(\.isVisible) { colorPanel, _ in 66 | if !colorPanel.isVisible { 67 | for case let colorWell as CWColorWell in colorPanel.attachedObjects { 68 | colorWell.deactivate() 69 | } 70 | } 71 | }, 72 | ] 73 | 74 | // MARK: Instance Properties 75 | 76 | final var backingStorage = BackingStorage() 77 | 78 | final var layoutView: CWColorWellLayoutView { 79 | enum Context { 80 | static let storage = ObjectAssociation() 81 | } 82 | // force cast is okay here; at this point it should be guaranteed 83 | // that self is an instance of CWColorWell or one of its subclasses 84 | let colorWell = self as! CWColorWell // swiftlint:disable:this force_cast 85 | if let layoutView = Context.storage[colorWell] { 86 | return layoutView 87 | } 88 | let layoutView = CWColorWellLayoutView(colorWell: colorWell) 89 | Context.storage[colorWell] = layoutView 90 | return layoutView 91 | } 92 | 93 | // MARK: Initializers 94 | 95 | public override init(frame frameRect: NSRect) { 96 | Self.earlySetup(type: Self.self) 97 | super.init(frame: frameRect) 98 | Self.sharedSetup(colorWell: self) 99 | } 100 | 101 | public required init?(coder: NSCoder) { 102 | Self.earlySetup(type: Self.self) 103 | super.init(coder: coder) 104 | Self.sharedSetup(colorWell: self) 105 | } 106 | 107 | // MARK: Deinit 108 | 109 | deinit { 110 | // color panel holds weak references to its attached objects, so 111 | // detaching isn't strictly necessary, but we want to ensure the 112 | // immediate removal of the box that holds the reference 113 | NSColorPanel.shared.detach(self) 114 | } 115 | 116 | // MARK: Setup Methods 117 | 118 | private static func earlySetup(type: _CWColorWellBaseControl.Type) { 119 | // fail as early as we can here 120 | precondition( 121 | type is CWColorWell.Type, 122 | """ 123 | Attempted to create instance of private class \(type). \ 124 | Use an instance of the public \(CWColorWell.self) class instead. 125 | """ 126 | ) 127 | _ = NSColorWell.swizzler 128 | _ = _CWColorWellBaseControl.colorPanelObservations 129 | } 130 | 131 | private static func sharedSetup(colorWell: _CWColorWellBaseControl) { 132 | colorWell.addSubview(colorWell.layoutView) 133 | } 134 | 135 | // MARK: Instance Methods 136 | 137 | /// Updates the color well's color to the given value, using 138 | /// the given options. 139 | func updateColor(_ newColor: NSColor?, options: ColorUpdateOptions) { } 140 | 141 | /// Computes and returns an intrinsic content size for the 142 | /// given control size. 143 | func computeIntrinsicContentSize(for controlSize: ControlSize) -> NSSize { 144 | // this implementation returns the same sizes as NSColorWell 145 | var size = BackingStorage.defaultSize 146 | switch backingStorage.style { 147 | case .default, .minimal: 148 | switch controlSize { 149 | case .large: 150 | size.height += 8 151 | case .regular: 152 | break // no change 153 | case .small: 154 | size.height -= 4 155 | case .mini: 156 | size.height -= 7 157 | @unknown default: 158 | break 159 | } 160 | case .expanded: 161 | size.width += CWToggleSegment.widthConstant 162 | switch controlSize { 163 | case .large: 164 | size.width += 8 165 | size.height += 8 166 | case .regular: 167 | break // no change 168 | case .small: 169 | size.width -= 4 170 | size.height -= 4 171 | case .mini: 172 | size.width -= 7 173 | size.height -= 7 174 | @unknown default: 175 | break 176 | } 177 | } 178 | return size 179 | } 180 | } 181 | 182 | // MARK: Overridden Properties 183 | extension _CWColorWellBaseControl { 184 | public override var acceptsFirstResponder: Bool { true } 185 | 186 | public override var alignmentRectInsets: NSEdgeInsets { 187 | NSEdgeInsets(top: 2, left: 3, bottom: 2, right: 3) 188 | } 189 | 190 | public override var cell: NSCell? { 191 | get { 192 | backingStorage.cell 193 | } 194 | set { 195 | backingStorage.cell = newValue 196 | } 197 | } 198 | 199 | public override var intrinsicContentSize: NSSize { 200 | computeIntrinsicContentSize(for: controlSize) 201 | } 202 | 203 | public override var isEnabled: Bool { 204 | get { 205 | backingStorage.isEnabled 206 | } 207 | set { 208 | backingStorage.isEnabled = newValue 209 | layoutView.setSegmentsNeedDisplay(true) 210 | } 211 | } 212 | 213 | public override var objectValue: Any? { 214 | get { 215 | backingStorage.color 216 | } 217 | set { 218 | guard let newColor = newValue as? NSColor? else { 219 | preconditionFailure("\(Self.self) objectValue must be a color object.") 220 | } 221 | updateColor(newColor, options: [ 222 | .informDelegate, 223 | .informObservers, 224 | ]) 225 | } 226 | } 227 | } 228 | 229 | // MARK: Overridden Methods 230 | extension _CWColorWellBaseControl { 231 | public override func keyDown(with event: NSEvent) { 232 | if 233 | let segment = layoutView.segments.first, 234 | segment.validateAndPerformAction(withKeyEvent: event) 235 | { 236 | return 237 | } 238 | super.keyDown(with: event) 239 | } 240 | } 241 | 242 | // MARK: Accessibility 243 | extension _CWColorWellBaseControl { 244 | public override func accessibilityChildren() -> [Any]? { 245 | if let toggleSegment = layoutView.segments.first(where: { $0 is CWToggleSegment }) { 246 | return [toggleSegment] 247 | } 248 | return [] 249 | } 250 | 251 | public override func accessibilityPerformPress() -> Bool { 252 | // when dealing with multiple segments, the designated press action 253 | // should be the one that enables the finest degree of color selection; 254 | // the last segment just happens to be the correct one 255 | return layoutView.segments.last?.accessibilityPerformPress() ?? false 256 | } 257 | 258 | public override func accessibilityRole() -> NSAccessibility.Role? { 259 | return .colorWell 260 | } 261 | 262 | public override func accessibilityValue() -> Any? { 263 | return String(describing: ColorInfo(color: backingStorage.color)) 264 | } 265 | 266 | public override func isAccessibilityElement() -> Bool { 267 | return true 268 | } 269 | 270 | public override func isAccessibilityEnabled() -> Bool { 271 | return isEnabled 272 | } 273 | } 274 | 275 | // MARK: - NSColorWell Swizzling 276 | 277 | private extension NSColorWell { 278 | @nonobjc static let swizzler: () = { 279 | let originalActivateSel = #selector(activate) 280 | let swizzledActivateSel = #selector(cw_swizzled_activate) 281 | let originalDeactivateSel = #selector(deactivate) 282 | let swizzledDeactivateSel = #selector(cw_swizzled_deactivate) 283 | 284 | guard 285 | let originalActivateMethod = class_getInstanceMethod(NSColorWell.self, originalActivateSel), 286 | let swizzledActivateMethod = class_getInstanceMethod(NSColorWell.self, swizzledActivateSel), 287 | let originalDeactivateMethod = class_getInstanceMethod(NSColorWell.self, originalDeactivateSel), 288 | let swizzledDeactivateMethod = class_getInstanceMethod(NSColorWell.self, swizzledDeactivateSel) 289 | else { 290 | return 291 | } 292 | 293 | method_exchangeImplementations(originalActivateMethod, swizzledActivateMethod) 294 | method_exchangeImplementations(originalDeactivateMethod, swizzledDeactivateMethod) 295 | }() 296 | 297 | // MARK: Activate 298 | 299 | @objc private func cw_swizzled_activate(_ exclusive: Bool) { 300 | // important that we capture the last attached object and its color 301 | // BEFORE activating and attaching, so we know what color to take 302 | let lastAttachedObject = NSColorPanel.shared.attachedObjects.last 303 | let lastAttachedObjectColor: NSColor? = lastAttachedObject?.color 304 | 305 | if exclusive { 306 | NSColorPanel.shared.enforceExclusivity(of: self) 307 | } 308 | 309 | // NOTE: since this method and the original have been swizzled, 310 | // a call to this method is actually a call to the original 311 | cw_swizzled_activate(exclusive) 312 | 313 | // attach to match CWColorWell's behavior 314 | NSColorPanel.shared.attach(self) 315 | 316 | if NSColorPanel.shared.isExclusivelyAttached(self) { 317 | return 318 | } 319 | 320 | if 321 | let lastAttachedObjectColor, 322 | color != lastAttachedObjectColor 323 | { 324 | color = lastAttachedObjectColor 325 | } 326 | } 327 | 328 | // MARK: Deactivate 329 | 330 | @objc private func cw_swizzled_deactivate() { 331 | // NOTE: since this method and the original have been swizzled, 332 | // a call to this method is actually a call to the original 333 | cw_swizzled_deactivate() 334 | 335 | // detach to match CWColorWell's behavior 336 | NSColorPanel.shared.detach(self) 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Utilities/Path.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Path.swift 3 | // ColorWellKit 4 | // 5 | 6 | import AppKit 7 | 8 | /// A generic interface over a graphics path. 9 | struct Path { 10 | /// An element in a path. 11 | enum Element { 12 | case move(to: CGPoint) 13 | case line(to: CGPoint) 14 | case quadCurve(to: CGPoint, control: CGPoint) 15 | case curve(to: CGPoint, control1: CGPoint, control2: CGPoint) 16 | case arc(through: CGPoint, to: CGPoint, radius: CGFloat) 17 | case compound(elements: [Element]) 18 | case close 19 | 20 | static func arc(around corner: Corner, ofRect rect: CGRect, radius: CGFloat) -> Element { 21 | let mid = corner.point(in: rect) 22 | 23 | let start: CGPoint 24 | let end: CGPoint 25 | 26 | switch corner { 27 | case .topLeading: 28 | start = mid.applying(CGAffineTransform(translationX: 0, y: -radius)) 29 | end = mid.applying(CGAffineTransform(translationX: radius, y: 0)) 30 | case .topTrailing: 31 | start = mid.applying(CGAffineTransform(translationX: -radius, y: 0)) 32 | end = mid.applying(CGAffineTransform(translationX: 0, y: -radius)) 33 | case .bottomTrailing: 34 | start = mid.applying(CGAffineTransform(translationX: 0, y: radius)) 35 | end = mid.applying(CGAffineTransform(translationX: -radius, y: 0)) 36 | case .bottomLeading: 37 | start = mid.applying(CGAffineTransform(translationX: radius, y: 0)) 38 | end = mid.applying(CGAffineTransform(translationX: 0, y: radius)) 39 | } 40 | 41 | return .compound(elements: [ 42 | .line(to: start), 43 | .arc(through: mid, to: end, radius: radius), 44 | ]) 45 | } 46 | } 47 | 48 | /// The elements that make up this path. 49 | private(set) var elements: [Element] 50 | 51 | /// Creates a path with the given elements. 52 | init(elements: [Element]) { 53 | self.elements = elements 54 | } 55 | 56 | /// Creates an empty path. 57 | init() { 58 | self.init(elements: []) 59 | } 60 | 61 | /// Creates a path from the elements of a Core Graphics path. 62 | init(cgPath: CGPath) { 63 | var elements = [Element]() 64 | cgPath.applyWithBlock { element in 65 | let points = element.pointee.points 66 | switch element.pointee.type { 67 | case .moveToPoint: 68 | elements.append(.move(to: points[0])) 69 | case .addLineToPoint: 70 | elements.append(.line(to: points[0])) 71 | case .addQuadCurveToPoint: 72 | elements.append(.quadCurve(to: points[1], control: points[0])) 73 | case .addCurveToPoint: 74 | elements.append(.curve(to: points[2], control1: points[0], control2: points[1])) 75 | case .closeSubpath: 76 | elements.append(.close) 77 | @unknown default: 78 | break 79 | } 80 | } 81 | self.init(elements: elements) 82 | } 83 | 84 | private static func cornerRadius(for controlSize: NSControl.ControlSize?) -> CGFloat { 85 | var radius: CGFloat = 5 86 | switch controlSize { 87 | case .large: 88 | radius += 0.25 89 | case .regular, .none: 90 | break // no change 91 | case .small: 92 | radius -= 1.25 93 | case .mini: 94 | radius -= 1.75 95 | @unknown default: 96 | break 97 | } 98 | return radius 99 | } 100 | 101 | static func colorWellPath( 102 | rect: CGRect, 103 | controlSize: NSControl.ControlSize?, 104 | flatteningEdge edge: Edge? = nil, 105 | shouldClose: Bool = true 106 | ) -> Path { 107 | let radius = cornerRadius(for: controlSize) 108 | let squaredCorners = edge?.corners ?? [] 109 | var elements: [Element] = Corner.clockwiseOrder.map { corner in 110 | if squaredCorners.contains(corner) { 111 | return .line(to: corner.point(in: rect)) 112 | } 113 | return .arc(around: corner, ofRect: rect, radius: radius) 114 | } 115 | if shouldClose { 116 | elements.append(.close) 117 | } 118 | return Path(elements: elements) 119 | } 120 | 121 | static func segmentPath( 122 | rect: CGRect, 123 | controlSize: NSControl.ControlSize?, 124 | segmentType: CWColorWellSegment.Type, 125 | shouldClose: Bool = true 126 | ) -> Path { 127 | // flatten the opposite edge to join up with the 128 | // segment on the other side 129 | let flatEdge = segmentType.edge?.opposite 130 | return colorWellPath( 131 | rect: rect, 132 | controlSize: controlSize, 133 | flatteningEdge: flatEdge, 134 | shouldClose: shouldClose 135 | ) 136 | } 137 | 138 | static func fullColorWellPath( 139 | rect: CGRect, 140 | controlSize: NSControl.ControlSize? 141 | ) -> Path { 142 | colorWellPath( 143 | rect: rect, 144 | controlSize: controlSize, 145 | flatteningEdge: nil, 146 | shouldClose: true 147 | ) 148 | } 149 | 150 | /// Creates and returns an equivalent `CGPath`. 151 | func cgPath() -> CGPath { 152 | func beginSubpath(in cgPath: CGMutablePath, at point: CGPoint, startPoint: inout CGPoint?) { 153 | cgPath.move(to: point) 154 | startPoint = point 155 | } 156 | 157 | func apply(element: Element, to cgPath: CGMutablePath, startPoint: inout CGPoint?) { 158 | switch element { 159 | case .move(let point): 160 | beginSubpath(in: cgPath, at: point, startPoint: &startPoint) 161 | case .line(let point): 162 | if cgPath.isEmpty { 163 | beginSubpath(in: cgPath, at: point, startPoint: &startPoint) 164 | } else { 165 | cgPath.addLine(to: point) 166 | } 167 | case .quadCurve(let point, let control): 168 | if cgPath.isEmpty { 169 | beginSubpath(in: cgPath, at: point, startPoint: &startPoint) 170 | } else { 171 | cgPath.addQuadCurve(to: point, control: control) 172 | } 173 | case .curve(let point, let control1, let control2): 174 | if cgPath.isEmpty { 175 | beginSubpath(in: cgPath, at: point, startPoint: &startPoint) 176 | } else { 177 | cgPath.addCurve(to: point, control1: control1, control2: control2) 178 | } 179 | case .arc(let midPoint, let endPoint, let radius): 180 | if cgPath.isEmpty { 181 | beginSubpath(in: cgPath, at: endPoint, startPoint: &startPoint) 182 | } else { 183 | cgPath.addArc(tangent1End: midPoint, tangent2End: endPoint, radius: radius) 184 | } 185 | case .compound(let elements): 186 | for element in elements { 187 | apply(element: element, to: cgPath, startPoint: &startPoint) 188 | } 189 | case .close: 190 | cgPath.closeSubpath() 191 | if let start = startPoint { 192 | beginSubpath(in: cgPath, at: start, startPoint: &startPoint) 193 | } 194 | } 195 | } 196 | 197 | let cgPath = CGMutablePath() 198 | var startPoint: CGPoint? 199 | 200 | for element in elements { 201 | apply(element: element, to: cgPath, startPoint: &startPoint) 202 | } 203 | 204 | return cgPath 205 | } 206 | 207 | /// Creates and returns an equivalent `NSBezierPath`. 208 | func nsBezierPath() -> NSBezierPath { 209 | func beginSubpath(in nsBezierPath: NSBezierPath, at point: CGPoint, startPoint: inout CGPoint?) { 210 | nsBezierPath.move(to: point) 211 | startPoint = point 212 | } 213 | 214 | func apply(element: Element, to nsBezierPath: NSBezierPath, startPoint: inout CGPoint?) { 215 | switch element { 216 | case .move(let point): 217 | beginSubpath(in: nsBezierPath, at: point, startPoint: &startPoint) 218 | case .line(let point): 219 | if nsBezierPath.isEmpty { 220 | beginSubpath(in: nsBezierPath, at: point, startPoint: &startPoint) 221 | } else { 222 | nsBezierPath.line(to: point) 223 | } 224 | case .quadCurve(let point, let control): 225 | if nsBezierPath.isEmpty { 226 | beginSubpath(in: nsBezierPath, at: point, startPoint: &startPoint) 227 | } else { 228 | nsBezierPath.curve(to: point, controlPoint1: control, controlPoint2: control) 229 | } 230 | case .curve(let point, let control1, let control2): 231 | if nsBezierPath.isEmpty { 232 | beginSubpath(in: nsBezierPath, at: point, startPoint: &startPoint) 233 | } else { 234 | nsBezierPath.curve(to: point, controlPoint1: control1, controlPoint2: control2) 235 | } 236 | case .arc(let midPoint, let endPoint, let radius): 237 | if nsBezierPath.isEmpty { 238 | beginSubpath(in: nsBezierPath, at: endPoint, startPoint: &startPoint) 239 | } else { 240 | nsBezierPath.appendArc(from: midPoint, to: endPoint, radius: radius) 241 | } 242 | case .compound(let elements): 243 | for element in elements { 244 | apply(element: element, to: nsBezierPath, startPoint: &startPoint) 245 | } 246 | case .close: 247 | nsBezierPath.close() 248 | if let start = startPoint { 249 | beginSubpath(in: nsBezierPath, at: start, startPoint: &startPoint) 250 | } 251 | } 252 | } 253 | 254 | let nsBezierPath = NSBezierPath() 255 | var startPoint: CGPoint? 256 | 257 | for element in elements { 258 | apply(element: element, to: nsBezierPath, startPoint: &startPoint) 259 | } 260 | 261 | return nsBezierPath 262 | } 263 | 264 | /// Fills the path with the given color using the specified winding 265 | /// rule, applying the given transformation matrix before performing 266 | /// the operation. 267 | func fill( 268 | with color: NSColor, 269 | windingRule: NSBezierPath.WindingRule = .nonZero, 270 | transform: AffineTransform = .identity 271 | ) { 272 | guard let context = NSGraphicsContext.current else { 273 | return 274 | } 275 | 276 | context.saveGraphicsState() 277 | defer { 278 | context.restoreGraphicsState() 279 | } 280 | 281 | color.setFill() 282 | 283 | let path = nsBezierPath() 284 | path.windingRule = windingRule 285 | path.transform(using: transform) 286 | path.fill() 287 | } 288 | 289 | /// Strokes the path with the given color, using the specified line 290 | /// width, line cap style, line join style, and miter limit, applying 291 | /// the given transformation matrix before performing the operation. 292 | func stroke( 293 | with color: NSColor, 294 | lineWidth: CGFloat = 1, 295 | lineCapStyle: NSBezierPath.LineCapStyle = .butt, 296 | lineJoinStyle: NSBezierPath.LineJoinStyle = .miter, 297 | miterLimit: CGFloat = 10, 298 | transform: AffineTransform = .identity 299 | ) { 300 | guard let context = NSGraphicsContext.current else { 301 | return 302 | } 303 | 304 | context.saveGraphicsState() 305 | defer { 306 | context.restoreGraphicsState() 307 | } 308 | 309 | color.setStroke() 310 | 311 | let path = nsBezierPath() 312 | path.lineWidth = lineWidth 313 | path.lineCapStyle = lineCapStyle 314 | path.lineJoinStyle = lineJoinStyle 315 | path.miterLimit = miterLimit 316 | path.transform(using: transform) 317 | path.stroke() 318 | } 319 | 320 | /// Returns a stroked version of the path. 321 | func stroked( 322 | lineWidth: CGFloat, 323 | lineCap: CGLineCap = .butt, 324 | lineJoin: CGLineJoin = .miter, 325 | miterLimit: CGFloat = 10.0, 326 | transform: CGAffineTransform = .identity 327 | ) -> Path { 328 | let stroked = cgPath().copy( 329 | strokingWithWidth: lineWidth, 330 | lineCap: lineCap, 331 | lineJoin: lineJoin, 332 | miterLimit: miterLimit, 333 | transform: transform 334 | ) 335 | return Path(cgPath: stroked) 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Utilities/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // ColorWellKit 4 | // 5 | 6 | import AppKit 7 | #if canImport(SwiftUI) 8 | import SwiftUI 9 | #endif 10 | 11 | // MARK: CGRect 12 | extension CGRect { 13 | /// Returns a new rectangle that is the result of centering the current 14 | /// rectangle within the bounds of another rectangle. 15 | func centered(in other: CGRect) -> CGRect { 16 | var copy = self 17 | copy.origin.x = other.midX - copy.width / 2 18 | copy.origin.y = other.midY - copy.height / 2 19 | return copy 20 | } 21 | } 22 | 23 | // MARK: NSColor 24 | extension NSColor { 25 | /// Returns the average of this color's red, green, and blue components, 26 | /// approximating the brightness of the color. 27 | var averageBrightness: CGFloat { 28 | guard let rgb = usingColorSpace(.displayP3) else { 29 | return 0 30 | } 31 | return (rgb.redComponent + rgb.greenComponent + rgb.blueComponent) / 3 32 | } 33 | 34 | /// Creates a color from a hexadecimal string. 35 | convenience init?(hexString: String) { 36 | func takeFirstComponent(from iterator: inout String.Iterator) -> CGFloat? { 37 | // assume 6 or 8 char (RRGGBB[AA]) strings (and ignore the 3 and 4 char 38 | // (RGB[A]) variants); try to eat the next 2 characters; if either call 39 | // to `next()` returns `nil`, the full component cannot be returned 40 | guard 41 | let c1 = iterator.next(), 42 | let c2 = iterator.next(), 43 | let component = Int(String([c1, c2]), radix: 16) 44 | else { 45 | return nil 46 | } 47 | return CGFloat(component) / 255 48 | } 49 | 50 | var iterator = hexString.trimmingCharacters(in: ["#"]).makeIterator() 51 | 52 | guard 53 | let r = takeFirstComponent(from: &iterator), 54 | let g = takeFirstComponent(from: &iterator), 55 | let b = takeFirstComponent(from: &iterator) 56 | else { 57 | return nil 58 | } 59 | let a = takeFirstComponent(from: &iterator) ?? 1 60 | 61 | self.init(srgbRed: r, green: g, blue: b, alpha: a) 62 | } 63 | 64 | /// Returns a Boolean value that indicates whether this color resembles 65 | /// the given color, using the specified tolerance. 66 | /// 67 | /// This method checks within the typical, non-grayscale color spaces. 68 | /// 69 | /// - Parameters: 70 | /// - other: A color to compare this color to. 71 | /// - tolerance: The maximum allowed difference per color component. 72 | func resembles(_ other: NSColor, tolerance: CGFloat = 0.0001) -> Bool { 73 | func resembles(using colorSpace: NSColorSpace) -> Bool { 74 | guard 75 | let first = usingColorSpace(colorSpace), 76 | let second = other.usingColorSpace(colorSpace) 77 | else { 78 | // one or both can't be converted 79 | return false 80 | } 81 | 82 | if first == second { 83 | // converted colors are equal 84 | return true 85 | } 86 | 87 | let (firstCount, secondCount) = ( 88 | first.numberOfComponents, 89 | second.numberOfComponents 90 | ) 91 | 92 | guard firstCount == secondCount else { 93 | // this shouldn't happen, as both colors have the same 94 | // color space, but check just in case 95 | return false 96 | } 97 | 98 | var components1 = [CGFloat](repeating: 0, count: firstCount) 99 | var components2 = [CGFloat](repeating: 0, count: secondCount) 100 | 101 | first.getComponents(&components1) 102 | second.getComponents(&components2) 103 | 104 | // if the difference between each component is within the 105 | // specified tolerance, return true 106 | return (0.. NSColor { 141 | let colorData: Data = { 142 | // Don't require secure coding. This is the entire reason we even 143 | // need this function. Certain NSColor-backed SwiftUI colors don't 144 | // support secure coding. Error messages point to a custom NSColor 145 | // subclass that SwiftUI uses behind the scenes. Its declaration 146 | // is internal to SwiftUI, so there isn't much we can do about it. 147 | // 148 | // An incredibly hacky solution is to archive the color where we 149 | // know secure coding isn't (shouldn't be?) needed, then create a 150 | // "pure" NSColor from the archived data. We could go the route of 151 | // converting the color to RGB beforehand, but that would arguably 152 | // be even more hacky, and would risk losing potentially important 153 | // color data. 154 | // 155 | // TODO: Investigate other solutions. 156 | let archiver = NSKeyedArchiver(requiringSecureCoding: false) 157 | 158 | encode(with: archiver) 159 | return archiver.encodedData 160 | }() 161 | 162 | guard 163 | let unarchiver = try? NSKeyedUnarchiver(forReadingFrom: colorData), 164 | let copy = NSColor(coder: unarchiver) 165 | else { 166 | // fall back to the original color if copying fails 167 | return self 168 | } 169 | 170 | return copy 171 | } 172 | } 173 | 174 | // MARK: NSColorPanel 175 | extension NSColorPanel { 176 | /// Box for a weak reference to an attached object. 177 | private class WeakObject { 178 | private(set) weak var object: AnyObject? 179 | 180 | init(object: AnyObject) { 181 | self.object = object 182 | } 183 | } 184 | 185 | /// Storage for the weak references to the color panel's attached objects. 186 | private static let weakObjectStorage = ObjectAssociation<[WeakObject]>() 187 | 188 | /// The objects that are currently attached to the color panel. 189 | var attachedObjects: [AnyObject] { 190 | get { 191 | Self.weakObjectStorage[self]?.compactMap { $0.object } ?? [] 192 | } 193 | set { 194 | Self.weakObjectStorage[self] = newValue.map { WeakObject(object: $0) } 195 | guard !newValue.isEmpty else { 196 | return 197 | } 198 | if let color: NSColor = newValue[0].color { 199 | self.color = color 200 | } 201 | for case let colorWell as CWColorWell in newValue[1...] { 202 | colorWell.updateColor(color, options: [ 203 | .informDelegate, 204 | .informObservers, 205 | .sendAction, 206 | ]) 207 | } 208 | } 209 | } 210 | 211 | /// Returns a Boolean value indicating whether the color panel's list of 212 | /// attached objects contains the specified object. 213 | func isAttached(_ object: AnyObject) -> Bool { 214 | attachedObjects.contains { $0 === object } 215 | } 216 | 217 | /// Returns a Boolean value indicating whether the given object is 218 | /// exclusively attached. 219 | func isExclusivelyAttached(_ object: AnyObject) -> Bool { 220 | attachedObjects.first === object && 221 | attachedObjects.count == 1 222 | } 223 | 224 | /// Returns a Boolean value indicating whether the given object is the 225 | /// main attached object, that is, the most recently attached object. 226 | /// 227 | /// The main attached object controls the color of the color panel. 228 | func isMainAttachedObject(_ object: AnyObject) -> Bool { 229 | attachedObjects.first === object 230 | } 231 | 232 | /// Adds the specified object to the color panel's list of attached objects. 233 | func attach(_ object: AnyObject) { 234 | guard !isAttached(object) else { 235 | return 236 | } 237 | attachedObjects.append(object) 238 | } 239 | 240 | /// Removes the specified object from the color panel's list of attached 241 | /// objects. 242 | func detach(_ object: AnyObject) { 243 | attachedObjects.removeAll { $0 === object } 244 | } 245 | 246 | /// Enforces the exclusivity of an object by detaching all other attached 247 | /// objects. 248 | /// 249 | /// If an attached object defines a `deactivate()` method, the object will 250 | /// be deactivated instead of detached. An assertion failure occurs in debug 251 | /// builds if the object is not manually detached during deactivation. 252 | func enforceExclusivity(of exclusiveObject: AnyObject) { 253 | for object in attachedObjects where object !== exclusiveObject { 254 | if let deactivate = object.deactivate { 255 | deactivate() 256 | assert(!isAttached(object), "Object not detached during deactivation.") 257 | } else { 258 | detach(object) 259 | } 260 | } 261 | } 262 | } 263 | 264 | // MARK: NSImage 265 | extension NSImage { 266 | /// Returns a new image by tinting the current image with the given color. 267 | /// 268 | /// - Parameters: 269 | /// - color: The color to tint the image to. 270 | /// - fraction: The amount of `color` to blend into the image. 271 | func tinted(to color: NSColor, fraction: CGFloat) -> NSImage { 272 | if fraction <= 0 { 273 | return self 274 | } 275 | 276 | let overlay = NSImage(size: size, flipped: false) { bounds in 277 | guard 278 | let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil), 279 | let context = NSGraphicsContext.current 280 | else { 281 | return false 282 | } 283 | color.setFill() 284 | context.cgContext.clip(to: bounds, mask: cgImage) 285 | context.cgContext.fill(bounds) 286 | return true 287 | } 288 | 289 | return NSImage(size: size, flipped: false) { bounds in 290 | self.draw(in: bounds) 291 | overlay.draw(in: bounds, from: .zero, operation: .sourceAtop, fraction: fraction) 292 | return true 293 | } 294 | } 295 | 296 | /// Returns an image by redrawing the current image with the given opacity. 297 | /// 298 | /// - Parameter opacity: The opacity of the returned image. 299 | func withOpacity(_ opacity: CGFloat) -> NSImage { 300 | if opacity >= 1 { 301 | return self 302 | } 303 | return NSImage(size: size, flipped: false) { bounds in 304 | self.draw(in: bounds, from: bounds, operation: .copy, fraction: opacity) 305 | return true 306 | } 307 | } 308 | } 309 | 310 | // MARK: NSView 311 | extension NSView { 312 | /// Returns this view's frame, converted to the coordinate system of 313 | /// its window. 314 | var frameConvertedToWindow: NSRect { 315 | superview?.convert(frame, to: nil) ?? frame 316 | } 317 | } 318 | 319 | // MARK: - SwiftUI Extensions - 320 | 321 | #if canImport(SwiftUI) 322 | 323 | // MARK: Binding where Value == CGColor 324 | @available(macOS 10.15, *) 325 | extension Binding where Value == CGColor { 326 | /// A binding to an `NSColor` derived from this binding. 327 | var nsColor: Binding { 328 | Binding( 329 | get: { NSColor(cgColor: wrappedValue) ?? .black }, 330 | set: { wrappedValue = $0.cgColor } 331 | ) 332 | } 333 | } 334 | 335 | // MARK: Binding where Value == Color 336 | @available(macOS 11.0, *) 337 | extension Binding where Value == Color { 338 | /// A binding to an `NSColor` derived from this binding. 339 | var nsColor: Binding { 340 | Binding( 341 | get: { NSColor(wrappedValue) }, 342 | set: { wrappedValue = Color($0) } 343 | ) 344 | } 345 | } 346 | #endif 347 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/Cocoa/CWColorWell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CWColorWell.swift 3 | // ColorWellKit 4 | // 5 | 6 | import AppKit 7 | 8 | /// A Cocoa control that displays a user-selectable color value. 9 | public class CWColorWell: _CWColorWellBaseControl { 10 | 11 | // MARK: Static Properties 12 | 13 | /// Hexadecimal strings used to construct the default colors shown 14 | /// in a color well's popover. 15 | private static let defaultHexStrings = [ 16 | "56C1FF", "72FDEA", "88FA4F", "FFF056", "FF968D", "FF95CA", 17 | "00A1FF", "15E6CF", "60D937", "FFDA31", "FF644E", "FF42A1", 18 | "0076BA", "00AC8E", "1FB100", "FEAE00", "ED220D", "D31876", 19 | "004D80", "006C65", "017101", "F27200", "B51800", "970E53", 20 | "FFFFFF", "D5D5D5", "929292", "5E5E5E", "000000", 21 | ] 22 | 23 | /// The default colors shown in a color well's popover. 24 | private static let defaultSwatchColors = defaultHexStrings.compactMap { string in 25 | NSColor(hexString: string) 26 | } 27 | 28 | // MARK: Instance Properties 29 | 30 | private let exclusivity = LockedState(initialState: true) 31 | 32 | private var popover: NSPopover? 33 | 34 | /// The color well's delegate object. 35 | public weak var delegate: CWColorWellDelegate? 36 | 37 | /// A Boolean value that indicates whether the color well supports being 38 | /// included in group selections. 39 | /// 40 | /// Users can make group selections by holding the `Shift` key while making 41 | /// a selection. If a newly-selected color well's `allowsMultipleSelection` 42 | /// property is `true` (the default), the selected color well becomes part 43 | /// of the current group selection. If no other color wells are selected, a 44 | /// new group selection is created. 45 | @objc dynamic 46 | public var allowsMultipleSelection: Bool = true 47 | 48 | /// The colors that will be shown as swatches in the color well's popover. 49 | /// 50 | /// The default values are defined according to the following hexadecimal 51 | /// codes: 52 | /// ```swift 53 | /// [ 54 | /// "56C1FF", "72FDEA", "88FA4F", "FFF056", "FF968D", "FF95CA", 55 | /// "00A1FF", "15E6CF", "60D937", "FFDA31", "FF644E", "FF42A1", 56 | /// "0076BA", "00AC8E", "1FB100", "FEAE00", "ED220D", "D31876", 57 | /// "004D80", "006C65", "017101", "F27200", "B51800", "970E53", 58 | /// "FFFFFF", "D5D5D5", "929292", "5E5E5E", "000000" 59 | /// ] 60 | /// ``` 61 | /// ![Default swatches](grid-view) 62 | /// 63 | /// You can add and remove values to change the swatches that are displayed. 64 | /// 65 | /// ```swift 66 | /// let colorWell = CWColorWell() 67 | /// colorWell.swatchColors += [ 68 | /// .systemPurple, 69 | /// .controlColor, 70 | /// .windowBackgroundColor 71 | /// ] 72 | /// colorWell.swatchColors.removeFirst() 73 | /// ``` 74 | /// 75 | /// Whatever value this property holds at the time the user opens the color 76 | /// well's popover is the value that will be used to construct its swatches. 77 | /// Each popover is constructed lazily, so if this value changes between 78 | /// popover sessions, the next popover that is displayed will reflect the 79 | /// changes. 80 | /// 81 | /// - Note: If the array is empty, the system color panel will be shown 82 | /// instead of the popover. 83 | @objc dynamic 84 | public var swatchColors = defaultSwatchColors 85 | 86 | /// The action to perform when the color area of the color well is pressed. 87 | /// 88 | /// By default, color wells with the ``Style-swift.enum/minimal`` or 89 | /// ``Style-swift.enum/expanded`` style display a popover with a grid of 90 | /// color swatches when the color area is pressed. If you specify a value 91 | /// for this property and the ``secondaryTarget`` property, clicks inside 92 | /// the color area execute your custom action method instead. 93 | public var secondaryAction: Selector? 94 | 95 | /// The target object that defines the action to perform when the color 96 | /// area of the color well is pressed. 97 | /// 98 | /// By default, color wells with the ``Style-swift.enum/minimal`` or 99 | /// ``Style-swift.enum/expanded`` style display a popover with a grid of 100 | /// color swatches when the color area is pressed. If you specify a value 101 | /// for this property and the ``secondaryAction`` property, clicks inside 102 | /// the color area execute your custom action method instead. 103 | public var secondaryTarget: AnyObject? 104 | 105 | /// The mode to switch the color panel to when the color well activates. 106 | public var colorPanelMode: NSColorPanel.Mode? 107 | 108 | /// The color well's color. 109 | /// 110 | /// Setting this value immediately updates the visual state of the color 111 | /// well. If the color well is active, the system color panel's color is 112 | /// updated to match the new value. 113 | @objc dynamic 114 | public var color: NSColor { 115 | get { 116 | backingStorage.color 117 | } 118 | set { 119 | updateColor(newValue, options: []) 120 | } 121 | } 122 | 123 | /// A Boolean value that indicates whether the color well is currently 124 | /// active. 125 | @objc dynamic 126 | public var isActive: Bool { 127 | get { 128 | NSColorPanel.shared.isAttached(self) 129 | } 130 | set { 131 | let shouldActivate = isEnabled && newValue 132 | defer { 133 | for segment in layoutView.segments { 134 | segment.updateForCurrentActiveState(shouldActivate) 135 | } 136 | if shouldActivate { 137 | if let colorPanelMode { 138 | NSColorPanel.shared.mode = colorPanelMode 139 | } 140 | NSColorPanel.shared.orderFront(self) 141 | } 142 | } 143 | if shouldActivate { 144 | if exclusivity.state && allowsMultipleSelection { 145 | NSColorPanel.shared.enforceExclusivity(of: self) 146 | } 147 | NSColorPanel.shared.attach(self) 148 | delegate?.colorWellDidActivate(self) 149 | } else { 150 | NSColorPanel.shared.detach(self) 151 | delegate?.colorWellDidDeactivate(self) 152 | } 153 | } 154 | } 155 | 156 | /// The appearance and behavior style to apply to the color well. 157 | /// 158 | /// The value of this property determines how the color well is displayed, 159 | /// and specifies how it should respond when someone interacts with it. 160 | /// 161 | /// For a list of possible values, see ``Style-swift.enum``. 162 | @objc dynamic 163 | public var style: Style { 164 | get { 165 | backingStorage.style 166 | } 167 | set { 168 | backingStorage.style = newValue 169 | invalidateIntrinsicContentSize() 170 | needsDisplay = true 171 | } 172 | } 173 | 174 | // MARK: Convenience Initializers 175 | 176 | /// Creates a color well with the specified color. 177 | /// 178 | /// - Parameter color: The initial color of the created color well. 179 | public convenience init(color: NSColor) { 180 | self.init() 181 | self.color = color 182 | } 183 | 184 | /// Creates a color well with the specified style. 185 | /// 186 | /// - Parameter style: The style of the created color well. 187 | public convenience init(style: Style) { 188 | self.init() 189 | self.style = style 190 | } 191 | 192 | // MARK: Public Instance Methods 193 | 194 | /// Activates the color well and displays the system color panel. 195 | /// 196 | /// Both elements will remain synchronized until either the color panel is closed 197 | /// or the color well is deactivated. 198 | /// 199 | /// - Parameter exclusive: A Boolean value that indicates whether the color well 200 | /// is activated with exclusive access to the system color panel. Passing `true` 201 | /// causes all other active color wells to be deactivated. Passing `false` 202 | /// activates the color well alongside any other color wells that are currently 203 | /// active. Note that color well exclusivity is only relevant during activation, 204 | /// and is not enforced after this method returns. 205 | public func activate(exclusive: Bool) { 206 | withExclusivityLock(exclusive) { isActive = true } 207 | } 208 | 209 | /// Deactivates the color well, detaching it from the system color panel. 210 | /// 211 | /// Until the color well is activated again, changes to the color panel will not 212 | /// affect the color well's state. 213 | public func deactivate() { 214 | withExclusivityLock(exclusivity.state) { isActive = false } 215 | } 216 | 217 | // MARK: Private/Internal Instance Methods 218 | 219 | /// Performs the given closure while locking the exclusivity of the color well 220 | /// to the given state in a thread-safe manner. 221 | private func withExclusivityLock(_ isExclusive: Bool, body: () throws -> T) rethrows -> T { 222 | try exclusivity.withLock { state in 223 | let cachedState = state 224 | state = isExclusive 225 | defer { 226 | state = cachedState 227 | } 228 | return try body() 229 | } 230 | } 231 | 232 | @objc(deactivate) // exposed to Objective-C as `deactivate` 233 | private func objcDeactivate() { 234 | deactivate() 235 | } 236 | 237 | /// Activates the color well, automatically determining whether it should be 238 | /// activated in an exclusive state. 239 | func activateAutoExclusive() { 240 | activate(exclusive: !NSEvent.modifierFlags.contains(.shift)) 241 | } 242 | 243 | /// Creates a popover according to the color well's popover configuration and 244 | /// shows it relative to the given segment. 245 | /// 246 | /// - Parameter segment: The segment in the color well relative to which the 247 | /// popover should be shown. If the segment does not belong to the color well, 248 | /// this method returns `false`. 249 | /// 250 | /// - Returns: `true` on success, `false` otherwise. 251 | @discardableResult 252 | func makeAndShowPopover(relativeTo segment: CWColorWellSegment) -> Bool { 253 | if popover != nil { 254 | // a popover is already being shown 255 | return false 256 | } 257 | guard layoutView.segments.contains(segment) else { 258 | return false 259 | } 260 | let popover = CWColorWellPopover(colorWell: self) 261 | 262 | // the popover is set to `nil` when it is closed; we use its presence to 263 | // determine whether the next call to this method should succeed or fail 264 | self.popover = popover 265 | 266 | popover.show(relativeTo: segment.frame, of: segment, preferredEdge: .minY) 267 | return true 268 | } 269 | 270 | /// Closes and sets the color well's popover to `nil`. 271 | func freePopover() { 272 | popover?.close() 273 | popover = nil 274 | } 275 | 276 | // MARK: Overridden Instance Methods 277 | 278 | override func updateColor(_ newColor: NSColor?, options: ColorUpdateOptions) { 279 | let newColor = newColor ?? .black 280 | 281 | guard backingStorage.color != newColor else { 282 | return 283 | } 284 | 285 | // set up a series of deferred blocks to execute in reverse order 286 | // once the color has been set 287 | var deferredBlocks = [(CWColorWell) -> Void]() 288 | 289 | // these get executed regardless of the options passed in 290 | deferredBlocks.append { colorWell in 291 | let colorPanel = NSColorPanel.shared 292 | if 293 | colorPanel.isMainAttachedObject(colorWell), 294 | colorPanel.color != colorWell.color 295 | { 296 | colorPanel.color = colorWell.color 297 | } 298 | } 299 | deferredBlocks.append { colorWell in 300 | colorWell.layoutView.setSegmentsNeedDisplay(true) 301 | } 302 | 303 | if options.contains(.sendAction) { 304 | deferredBlocks.append { colorWell in 305 | colorWell.sendAction(colorWell.action, to: colorWell.target) 306 | } 307 | } 308 | if options.contains(.informObservers) { 309 | willChangeValue(for: \.color) 310 | deferredBlocks.append { colorWell in 311 | colorWell.didChangeValue(for: \.color) 312 | } 313 | } 314 | if options.contains(.informDelegate) { 315 | delegate?.colorWellWillChangeColor(self, to: newColor) 316 | deferredBlocks.append { colorWell in 317 | colorWell.delegate?.colorWellDidChangeColor(colorWell) 318 | } 319 | } 320 | 321 | backingStorage.color = newColor 322 | 323 | while let block = deferredBlocks.popLast() { 324 | block(self) 325 | } 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/Cocoa/CWColorWellPopover.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CWColorWellPopover.swift 3 | // ColorWellKit 4 | // 5 | 6 | import AppKit 7 | 8 | // MARK: - CWColorWellPopover 9 | 10 | /// A popover that contains a grid of selectable color swatches. 11 | class CWColorWellPopover: NSPopover, NSPopoverDelegate { 12 | private weak var colorWell: CWColorWell? 13 | 14 | init(colorWell: CWColorWell) { 15 | self.colorWell = colorWell 16 | super.init() 17 | self.contentViewController = ContentViewController(colorWell: colorWell) 18 | self.behavior = .transient 19 | self.delegate = self 20 | } 21 | 22 | @available(*, unavailable) 23 | required init?(coder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | func popoverDidClose(_ notification: Notification) { 28 | DispatchQueue.main.async { [weak colorWell] in 29 | colorWell?.freePopover() 30 | } 31 | } 32 | 33 | override func show(relativeTo positioningRect: NSRect, of positioningView: NSView, preferredEdge: NSRectEdge) { 34 | super.show(relativeTo: positioningRect, of: positioningView, preferredEdge: preferredEdge) 35 | guard 36 | let color = colorWell?.color, 37 | let contentViewController = contentViewController as? ContentViewController 38 | else { 39 | return 40 | } 41 | contentViewController.contentView.layoutView.swatchLayout.selectSwatch(matching: color) 42 | } 43 | } 44 | 45 | // MARK: - ContentViewController 46 | 47 | extension CWColorWellPopover { 48 | private class ContentViewController: NSViewController { 49 | let contentView: ContentView 50 | 51 | init(colorWell: CWColorWell) { 52 | self.contentView = ContentView(colorWell: colorWell) 53 | super.init(nibName: nil, bundle: nil) 54 | self.view = self.contentView 55 | } 56 | 57 | @available(*, unavailable) 58 | required init?(coder: NSCoder) { 59 | fatalError("init(coder:) has not been implemented") 60 | } 61 | } 62 | } 63 | 64 | // MARK: - ContentView 65 | 66 | extension CWColorWellPopover { 67 | private class ContentView: NSView { 68 | let layoutView: LayoutView 69 | 70 | init(colorWell: CWColorWell) { 71 | self.layoutView = LayoutView(colorWell: colorWell) 72 | super.init(frame: .zero) 73 | addSubview(self.layoutView) 74 | setPadding() 75 | } 76 | 77 | @available(*, unavailable) 78 | required init?(coder: NSCoder) { 79 | fatalError("init(coder:) has not been implemented") 80 | } 81 | 82 | func setPadding() { 83 | guard layoutView.superview === self else { 84 | cw_log( 85 | "Popover layout view is missing from its expected superview.", 86 | category: .popover 87 | ) 88 | return 89 | } 90 | 91 | removeConstraints(constraints) 92 | layoutView.removeConstraints(layoutView.constraints) 93 | 94 | translatesAutoresizingMaskIntoConstraints = false 95 | NSLayoutConstraint.activate([ 96 | widthAnchor.constraint(equalTo: layoutView.widthAnchor, constant: 20), 97 | heightAnchor.constraint(equalTo: layoutView.heightAnchor, constant: 20), 98 | ]) 99 | 100 | layoutView.translatesAutoresizingMaskIntoConstraints = false 101 | NSLayoutConstraint.activate([ 102 | layoutView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), 103 | layoutView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), 104 | layoutView.topAnchor.constraint(equalTo: topAnchor, constant: 10), 105 | layoutView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10), 106 | ]) 107 | } 108 | } 109 | } 110 | 111 | // MARK: - LayoutView 112 | 113 | extension CWColorWellPopover { 114 | private class LayoutView: NSGridView { 115 | private weak var colorWell: CWColorWell? 116 | let swatchLayout: SwatchLayout 117 | 118 | init(colorWell: CWColorWell) { 119 | self.colorWell = colorWell 120 | self.swatchLayout = SwatchLayout(colorWell: colorWell) 121 | super.init(frame: .zero) 122 | addRow(with: [self.swatchLayout]) 123 | 124 | if colorWell.style == .minimal { 125 | let activationButton = NSButton( 126 | title: "Show More Colors…", 127 | target: self, 128 | action: #selector(activateColorWell) 129 | ) 130 | 131 | activationButton.bezelStyle = .recessed 132 | activationButton.controlSize = .small 133 | 134 | addRow(with: [activationButton]) 135 | cell(for: activationButton)?.xPlacement = .fill 136 | } 137 | } 138 | 139 | @available(*, unavailable) 140 | required init?(coder: NSCoder) { 141 | fatalError("init(coder:) has not been implemented") 142 | } 143 | 144 | @objc private func activateColorWell() { 145 | colorWell?.activateAutoExclusive() 146 | colorWell?.freePopover() 147 | } 148 | } 149 | } 150 | 151 | // MARK: - SwatchLayout 152 | 153 | extension CWColorWellPopover { 154 | private class SwatchLayout: NSGridView { 155 | private let selectionIndicator = SelectionIndicator() 156 | private(set) var swatches = [ColorSwatch]() 157 | 158 | init(colorWell: CWColorWell) { 159 | super.init(frame: .zero) 160 | self.rowSpacing = 1 161 | self.columnSpacing = 1 162 | setRows(with: colorWell) 163 | updateSelectionIndicator() 164 | } 165 | 166 | @available(*, unavailable) 167 | required init?(coder: NSCoder) { 168 | fatalError("init(coder:) has not been implemented") 169 | } 170 | 171 | @discardableResult 172 | private func addSwatchRow(with swatches: [ColorSwatch]) -> NSGridRow { 173 | let row = addRow(with: swatches) 174 | self.swatches.append(contentsOf: swatches) 175 | return row 176 | } 177 | 178 | private func setRows(with colorWell: CWColorWell) { 179 | guard swatches.isEmpty else { 180 | cw_log("SwatchLayout rows already set", category: .popover) 181 | return 182 | } 183 | 184 | var currentSwatches = [ColorSwatch]() 185 | 186 | for color in colorWell.swatchColors { 187 | if currentSwatches.count >= 6 { 188 | addSwatchRow(with: currentSwatches) 189 | currentSwatches.removeAll() 190 | } 191 | currentSwatches.append( 192 | ColorSwatch( 193 | color: color, 194 | size: NSSize(width: 37, height: 20), 195 | colorWell: colorWell, 196 | swatchLayout: self 197 | ) 198 | ) 199 | } 200 | 201 | if !currentSwatches.isEmpty { 202 | addSwatchRow(with: currentSwatches) 203 | } 204 | } 205 | 206 | func selectSwatch(matching color: NSColor) { 207 | var matchingSwatch: ColorSwatch? 208 | 209 | swatchLoop: 210 | for swatch in swatches where swatch.color.resembles(color) { 211 | matchingSwatch = swatch 212 | switch (swatch.color.type, color.type) { 213 | case (.componentBased, .componentBased): 214 | if swatch.color.colorSpace == color.colorSpace { 215 | break swatchLoop 216 | } 217 | case (.pattern, .pattern): 218 | if swatch.color.patternImage == color.patternImage { 219 | break swatchLoop 220 | } 221 | case (.catalog, .catalog): 222 | if 223 | swatch.color.catalogNameComponent == color.catalogNameComponent, 224 | swatch.color.colorNameComponent == color.colorNameComponent 225 | { 226 | break swatchLoop 227 | } 228 | case (.componentBased, _), (.pattern, _), (.catalog, _): 229 | continue swatchLoop 230 | @unknown default: 231 | continue swatchLoop 232 | } 233 | } 234 | 235 | matchingSwatch?.select() 236 | } 237 | 238 | func updateSelectionIndicator() { 239 | guard let selectedSwatch = swatches.first(where: { $0.isSelected }) else { 240 | selectionIndicator.removeFromSuperview() 241 | return 242 | } 243 | if selectionIndicator.superview !== self { 244 | addSubview(selectionIndicator) 245 | } 246 | selectionIndicator.frame = selectedSwatch.frame 247 | } 248 | 249 | func swatch(at point: NSPoint) -> ColorSwatch? { 250 | swatches.first { swatch in 251 | swatch.frameConvertedToWindow.contains(point) 252 | } 253 | } 254 | 255 | override func mouseDragged(with event: NSEvent) { 256 | super.mouseDragged(with: event) 257 | swatch(at: event.locationInWindow)?.select() 258 | } 259 | 260 | override func mouseUp(with event: NSEvent) { 261 | super.mouseUp(with: event) 262 | guard 263 | let selectedSwatch = swatches.first(where: { $0.isSelected }), 264 | swatch(at: event.locationInWindow) === selectedSwatch 265 | else { 266 | return 267 | } 268 | selectedSwatch.performAction() 269 | } 270 | } 271 | } 272 | 273 | // MARK: - SelectionIndicator 274 | 275 | extension CWColorWellPopover { 276 | private class SelectionIndicator: NSView { 277 | override init(frame frameRect: NSRect) { 278 | super.init(frame: frameRect) 279 | let shadow = NSShadow() 280 | shadow.shadowOffset = .zero 281 | shadow.shadowBlurRadius = 1 282 | shadow.shadowColor = .shadowColor.withAlphaComponent(0.5) 283 | self.shadow = shadow 284 | self.wantsLayer = true 285 | self.layer?.masksToBounds = false 286 | } 287 | 288 | @available(*, unavailable) 289 | required init?(coder: NSCoder) { 290 | fatalError("init(coder:) has not been implemented") 291 | } 292 | 293 | override func draw(_ dirtyRect: NSRect) { 294 | NSColor.white.setStroke() 295 | let path = NSBezierPath(rect: bounds) 296 | path.lineWidth = 3 297 | path.lineJoinStyle = .round 298 | path.stroke() 299 | } 300 | } 301 | } 302 | 303 | // MARK: - ColorSwatch 304 | 305 | extension CWColorWellPopover { 306 | private class ColorSwatch: NSView { 307 | private weak var colorWell: CWColorWell? 308 | private weak var swatchLayout: SwatchLayout? 309 | 310 | let color: NSColor 311 | private(set) var isSelected: Bool = false 312 | 313 | init( 314 | color: NSColor, 315 | size: NSSize, 316 | colorWell: CWColorWell?, 317 | swatchLayout: SwatchLayout? 318 | ) { 319 | self.color = color 320 | super.init(frame: .zero) 321 | self.colorWell = colorWell 322 | self.swatchLayout = swatchLayout 323 | self.wantsLayer = true 324 | self.translatesAutoresizingMaskIntoConstraints = false 325 | NSLayoutConstraint.activate([ 326 | widthAnchor.constraint(equalToConstant: size.width), 327 | heightAnchor.constraint(equalToConstant: size.height), 328 | ]) 329 | } 330 | 331 | @available(*, unavailable) 332 | required init?(coder: NSCoder) { 333 | fatalError("init(coder:) has not been implemented") 334 | } 335 | 336 | func select() { 337 | guard 338 | !isSelected, 339 | let swatchLayout 340 | else { 341 | return 342 | } 343 | isSelected = true 344 | // unselect all other swatches 345 | for swatch in swatchLayout.swatches where swatch.isSelected && swatch !== self { 346 | swatch.isSelected = false 347 | } 348 | swatchLayout.updateSelectionIndicator() 349 | } 350 | 351 | func performAction() { 352 | colorWell?.updateColor(color, options: [ 353 | .informDelegate, 354 | .informObservers, 355 | .sendAction, 356 | ]) 357 | colorWell?.freePopover() 358 | } 359 | 360 | override func draw(_ dirtyRect: NSRect) { 361 | guard let context = NSGraphicsContext.current else { 362 | return 363 | } 364 | 365 | context.saveGraphicsState() 366 | defer { 367 | context.restoreGraphicsState() 368 | } 369 | 370 | context.compositingOperation = .multiply 371 | 372 | let color = color.usingColorSpace(.displayP3) ?? color 373 | color.drawSwatch(in: bounds) 374 | NSColor(white: 1 - color.averageBrightness, alpha: 0.3).setStroke() 375 | let path = NSBezierPath(rect: bounds.insetBy(dx: 1, dy: 1)) 376 | path.lineWidth = 2 377 | path.stroke() 378 | } 379 | 380 | override func mouseDown(with event: NSEvent) { 381 | super.mouseDown(with: event) 382 | // this just performs the preliminary selection; 383 | // the action is handled by LayoutView's mouseUp 384 | select() 385 | } 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /Sources/ColorWellKit/Views/Cocoa/CWColorWellSegment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CWColorWellSegment.swift 3 | // ColorWellKit 4 | // 5 | 6 | import AppKit 7 | 8 | // MARK: - CWColorWellSegment 9 | 10 | /// A view that draws a segmented portion of a color well. 11 | class CWColorWellSegment: NSView { 12 | /// A type that represents the state of a color well segment. 13 | enum State { 14 | case `default` 15 | case hover 16 | case highlight 17 | case pressed 18 | } 19 | 20 | /// The edge of a color well where segments of this type are drawn. 21 | /// 22 | /// The value of this property specifies how the color well should be 23 | /// drawn, specifically, whether it should be drawn as a continuous 24 | /// rounded rectangle, or as a partial rounded rectangle that makes up 25 | /// a segment in the final shape, with one of its sides drawn with a 26 | /// flat edge to match up with the segment on the opposite side. 27 | /// 28 | /// Any value other than `nil` indicates that the segment should be 29 | /// drawn as a partial rounded rectangle. A `nil` value indicates that 30 | /// the segment fills the entire bounds of the color well, and should 31 | /// be drawn as a continuous rounded rectangle. 32 | /// 33 | /// The default value for the base segment class is `nil`, and should 34 | /// be overridden by subclasses. 35 | class var edge: Edge? { nil } 36 | 37 | weak var colorWell: CWColorWell? 38 | 39 | /// The current and previous states of the segment. 40 | var backingStates = (current: State.default, previous: State.default) 41 | 42 | /// The current state of the segment. 43 | /// 44 | /// Updating this property displays the segment, if the value returned 45 | /// from `needsDisplayOnStateChange(_:)` is `true`. 46 | var state: State { 47 | get { 48 | backingStates.current 49 | } 50 | set { 51 | backingStates = (newValue, state) 52 | if needsDisplayOnStateChange(newValue) { 53 | needsDisplay = true 54 | } 55 | } 56 | } 57 | 58 | /// Passthrough of `isActive` on `colorWell`. 59 | var isActive: Bool { 60 | colorWell?.isActive ?? false 61 | } 62 | 63 | /// Passthrough of `isEnabled` on `colorWell`. 64 | var isEnabled: Bool { 65 | colorWell?.isEnabled ?? false 66 | } 67 | 68 | /// The default fill color for a color well segment. 69 | var segmentColor: NSColor { 70 | switch ColorScheme.current { 71 | case .light: .controlColor 72 | case .dark: .selectedControlColor 73 | } 74 | } 75 | 76 | /// The fill color for a highlighted color well segment. 77 | var highlightedSegmentColor: NSColor { 78 | switch ColorScheme.current { 79 | case .light: segmentColor.blended(withFraction: 0.5, of: .selectedControlColor) ?? segmentColor 80 | case .dark: segmentColor.blended(withFraction: 0.2, of: .highlightColor) ?? segmentColor 81 | } 82 | } 83 | 84 | /// The fill color for a selected color well segment. 85 | var selectedSegmentColor: NSColor { 86 | switch ColorScheme.current { 87 | case .light: .selectedControlColor 88 | case .dark: segmentColor.withAlphaComponent(segmentColor.alphaComponent + 0.25) 89 | } 90 | } 91 | 92 | /// The unaltered fill color of the segment. 93 | var rawColor: NSColor { segmentColor } 94 | 95 | /// The color that is displayed directly in the segment. 96 | var displayColor: NSColor { rawColor } 97 | 98 | override var acceptsFirstResponder: Bool { true } 99 | 100 | override var needsPanelToBecomeKey: Bool { false } 101 | 102 | override var focusRingMaskBounds: NSRect { bounds } 103 | 104 | /// Creates a segment for the given color well. 105 | init(colorWell: CWColorWell) { 106 | super.init(frame: .zero) 107 | self.colorWell = colorWell 108 | self.wantsLayer = true 109 | updateForCurrentActiveState(colorWell.isActive) 110 | } 111 | 112 | @available(*, unavailable) 113 | required init?(coder: NSCoder) { 114 | fatalError("init(coder:) has not been implemented") 115 | } 116 | 117 | /// Performs a predefined action for this segment class using the given segment. 118 | /// 119 | /// Subclasses should override this method to provide their own custom behavior. 120 | /// It is defined as a class method to allow a given implementation to delegate 121 | /// to an implementation belonging to a different segment class. 122 | /// 123 | /// - Parameter segment: A segment to perform the action with. 124 | /// 125 | /// - Returns: A Boolean value indicating whether the action was successfully 126 | /// performed. 127 | class func performAction(for segment: CWColorWellSegment) -> Bool { false } 128 | 129 | /// Performs the segment's action using the given key event, after 130 | /// performing validation on the key event to ensure it can be used 131 | /// to perform the action. 132 | /// 133 | /// The segment must be enabled in order to successfully perform its 134 | /// action. The event must be a key-down event, and its `characters` 135 | /// property must consist of a single space (U+0020) character. If 136 | /// these conditions are not met, or performing the action otherwise 137 | /// fails, this method returns `false`. 138 | /// 139 | /// - Parameter event: A key event to validate. 140 | /// 141 | /// - Returns: A Boolean value indicating whether the action was 142 | /// successfully performed. 143 | func validateAndPerformAction(withKeyEvent event: NSEvent) -> Bool { 144 | if 145 | isEnabled, 146 | event.type == .keyDown, 147 | event.characters == "\u{0020}" // space 148 | { 149 | return Self.performAction(for: self) 150 | } 151 | return false 152 | } 153 | 154 | /// Updates the state of the segment to match the specified active state. 155 | func updateForCurrentActiveState(_ isActive: Bool) { } 156 | 157 | /// Invoked to return whether the segment should be redrawn after its state changes. 158 | func needsDisplayOnStateChange(_ state: State) -> Bool { false } 159 | 160 | /// Returns the path to draw the segment in the given rectangle. 161 | func segmentPath(_ rect: NSRect) -> Path { 162 | Path.segmentPath( 163 | rect: rect, 164 | controlSize: colorWell?.controlSize, 165 | segmentType: Self.self 166 | ) 167 | } 168 | 169 | override func draw(_ dirtyRect: NSRect) { 170 | segmentPath(bounds).fill(with: displayColor) 171 | } 172 | 173 | override func drawFocusRingMask() { 174 | segmentPath(focusRingMaskBounds).fill(with: .black) 175 | } 176 | 177 | override func acceptsFirstMouse(for event: NSEvent?) -> Bool { 178 | return true 179 | } 180 | 181 | override func mouseDown(with event: NSEvent) { 182 | super.mouseDown(with: event) 183 | guard isEnabled else { 184 | return 185 | } 186 | state = .highlight 187 | } 188 | 189 | override func mouseUp(with event: NSEvent) { 190 | super.mouseUp(with: event) 191 | guard 192 | isEnabled, 193 | frameConvertedToWindow.contains(event.locationInWindow) 194 | else { 195 | return 196 | } 197 | _ = Self.performAction(for: self) 198 | } 199 | 200 | override func keyDown(with event: NSEvent) { 201 | if !validateAndPerformAction(withKeyEvent: event) { 202 | super.keyDown(with: event) 203 | } 204 | } 205 | 206 | override func accessibilityParent() -> Any? { 207 | return colorWell 208 | } 209 | 210 | override func accessibilityPerformPress() -> Bool { 211 | Self.performAction(for: self) 212 | } 213 | 214 | override func accessibilityRole() -> NSAccessibility.Role? { 215 | return .button 216 | } 217 | 218 | override func isAccessibilityElement() -> Bool { 219 | return true 220 | } 221 | } 222 | 223 | // MARK: - CWSwatchSegment 224 | 225 | /// A segment that displays a color swatch with the color well's current 226 | /// color selection. 227 | class CWSwatchSegment: CWColorWellSegment { 228 | /// Dragging information associated with a color well segment. 229 | struct DraggingInformation { 230 | /// The default values for this instance. 231 | private let defaults: (threshold: CGFloat, isDragging: Bool, offset: CGSize) 232 | 233 | /// The amount of movement that must occur before a dragging 234 | /// session can start. 235 | var threshold: CGFloat 236 | 237 | /// A Boolean value that indicates whether a drag is currently 238 | /// in progress. 239 | var isDragging: Bool 240 | 241 | /// The accumulated offset of the current series of dragging 242 | /// events. 243 | var offset: CGSize 244 | 245 | /// A Boolean value that indicates whether the current dragging 246 | /// information is valid for starting a dragging session. 247 | var isValid: Bool { 248 | hypot(offset.width, offset.height) >= threshold 249 | } 250 | 251 | /// Creates an instance with the given values. 252 | /// 253 | /// The values that are provided here will be cached, and used 254 | /// to reset the instance. 255 | init( 256 | threshold: CGFloat = 4, 257 | isDragging: Bool = false, 258 | offset: CGSize = CGSize() 259 | ) { 260 | self.defaults = (threshold, isDragging, offset) 261 | self.threshold = threshold 262 | self.isDragging = isDragging 263 | self.offset = offset 264 | } 265 | 266 | /// Resets the dragging information to its default values. 267 | mutating func reset() { 268 | self = DraggingInformation( 269 | threshold: defaults.threshold, 270 | isDragging: defaults.isDragging, 271 | offset: defaults.offset 272 | ) 273 | } 274 | 275 | /// Updates the segment's dragging offset according to the x and y 276 | /// deltas of the given event. 277 | mutating func updateOffset(with event: NSEvent) { 278 | offset.width += event.deltaX 279 | offset.height += event.deltaY 280 | } 281 | } 282 | 283 | var draggingInformation = DraggingInformation() 284 | 285 | var borderColor: NSColor { 286 | let displayColor = displayColor 287 | let component = min(displayColor.averageBrightness, displayColor.alphaComponent) 288 | let limitedComponent = min(component, 0.3) 289 | let white = 1 - limitedComponent 290 | let alpha = min(limitedComponent * 1.3, 0.7) 291 | return NSColor(white: white, alpha: alpha) 292 | } 293 | 294 | override var rawColor: NSColor { 295 | colorWell?.color ?? super.rawColor 296 | } 297 | 298 | override var displayColor: NSColor { 299 | super.displayColor.usingColorSpace(.displayP3) ?? super.displayColor 300 | } 301 | 302 | override var acceptsFirstResponder: Bool { false } 303 | 304 | override init(colorWell: CWColorWell) { 305 | super.init(colorWell: colorWell) 306 | registerForDraggedTypes([.color]) 307 | } 308 | 309 | /// Draws the segment's swatch. 310 | @objc dynamic 311 | func drawSwatch() { 312 | guard let context = NSGraphicsContext.current else { 313 | return 314 | } 315 | 316 | context.saveGraphicsState() 317 | defer { 318 | context.restoreGraphicsState() 319 | } 320 | 321 | // workaround for the clipping path affecting the border of the swatch: draw the 322 | // swatch as an image before clipping, then clip the image instead of the swatch 323 | let swatchImage = NSImage(size: bounds.size, flipped: false) { [weak displayColor] bounds in 324 | guard let displayColor else { 325 | return false 326 | } 327 | displayColor.drawSwatch(in: bounds) 328 | return true 329 | } 330 | 331 | segmentPath(bounds).nsBezierPath().addClip() 332 | swatchImage.draw(in: bounds) 333 | } 334 | 335 | override func draw(_ dirtyRect: NSRect) { 336 | drawSwatch() 337 | } 338 | 339 | override func mouseDown(with event: NSEvent) { 340 | super.mouseDown(with: event) 341 | draggingInformation.reset() 342 | } 343 | 344 | override func mouseUp(with event: NSEvent) { 345 | defer { 346 | draggingInformation.reset() 347 | } 348 | guard !draggingInformation.isDragging else { 349 | return 350 | } 351 | super.mouseUp(with: event) 352 | } 353 | 354 | override func mouseDragged(with event: NSEvent) { 355 | super.mouseDragged(with: event) 356 | 357 | guard isEnabled else { 358 | return 359 | } 360 | 361 | draggingInformation.updateOffset(with: event) 362 | 363 | guard 364 | draggingInformation.isValid, 365 | let color = colorWell?.color 366 | else { 367 | return 368 | } 369 | 370 | draggingInformation.isDragging = true 371 | state = backingStates.previous 372 | 373 | let colorForDragging = color.createArchivedCopy() 374 | NSColorPanel.dragColor(colorForDragging, with: event, from: self) 375 | } 376 | 377 | override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { 378 | guard 379 | isEnabled, 380 | let types = sender.draggingPasteboard.types, 381 | types.contains(where: { registeredDraggedTypes.contains($0) }) 382 | else { 383 | return [] 384 | } 385 | return .move 386 | } 387 | 388 | override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { 389 | if 390 | let colorWell, 391 | let color = NSColor(from: sender.draggingPasteboard) 392 | { 393 | colorWell.updateColor(color, options: [ 394 | .informDelegate, 395 | .informObservers, 396 | .sendAction, 397 | ]) 398 | return true 399 | } 400 | return false 401 | } 402 | 403 | override func isAccessibilityElement() -> Bool { 404 | return false 405 | } 406 | } 407 | 408 | // MARK: - CWBorderedSwatchSegment 409 | 410 | /// A segment that displays a color swatch with the color well's current 411 | /// color selection, and that toggles the color panel when pressed. 412 | class CWBorderedSwatchSegment: CWSwatchSegment { 413 | override class var edge: Edge? { nil } 414 | 415 | var bezelColor: NSColor { 416 | let bezelColor: NSColor = switch state { 417 | case .highlight, .pressed: 418 | switch ColorScheme.current { 419 | case .light: 420 | selectedSegmentColor 421 | case .dark: 422 | .highlightColor 423 | } 424 | default: 425 | segmentColor 426 | } 427 | guard isEnabled else { 428 | let alphaComponent = max(bezelColor.alphaComponent - 0.5, 0.1) 429 | return bezelColor.withAlphaComponent(alphaComponent) 430 | } 431 | return bezelColor 432 | } 433 | 434 | override var borderColor: NSColor { 435 | switch ColorScheme.current { 436 | case .light: super.borderColor.blended(withFraction: 0.25, of: .controlTextColor) ?? super.borderColor 437 | case .dark: super.borderColor 438 | } 439 | } 440 | 441 | override class func performAction(for segment: CWColorWellSegment) -> Bool { 442 | CWToggleSegment.performAction(for: segment) 443 | } 444 | 445 | override func drawSwatch() { 446 | guard let context = NSGraphicsContext.current else { 447 | return 448 | } 449 | 450 | context.saveGraphicsState() 451 | defer { 452 | context.restoreGraphicsState() 453 | } 454 | 455 | segmentPath(bounds).fill(with: bezelColor) 456 | 457 | var inset: CGFloat = 3 458 | var radius: CGFloat = 2 459 | switch colorWell?.controlSize { 460 | case .large: 461 | inset += 0.25 462 | radius += 0.2 463 | case .regular, .none: 464 | break // no change 465 | case .small: 466 | inset -= 0.75 467 | radius -= 0.6 468 | case .mini: 469 | inset -= 1 470 | radius -= 0.8 471 | @unknown default: 472 | break 473 | } 474 | 475 | let clippingPath = NSBezierPath( 476 | roundedRect: bounds.insetBy(dx: inset, dy: inset), 477 | xRadius: radius, 478 | yRadius: radius 479 | ) 480 | 481 | clippingPath.lineWidth = 1 482 | clippingPath.addClip() 483 | 484 | displayColor.drawSwatch(in: bounds) 485 | 486 | borderColor.setStroke() 487 | clippingPath.stroke() 488 | } 489 | 490 | override func updateForCurrentActiveState(_ isActive: Bool) { 491 | state = isActive ? .pressed : .default 492 | } 493 | 494 | override func needsDisplayOnStateChange(_ state: State) -> Bool { 495 | state != .hover 496 | } 497 | } 498 | 499 | // MARK: - CWPullDownSwatchSegment 500 | 501 | /// A segment that displays a color swatch with the color well's current 502 | /// color selection, and that triggers a pull-down action when pressed. 503 | class CWPullDownSwatchSegment: CWSwatchSegment { 504 | private var mouseEnterExitTrackingArea: NSTrackingArea? 505 | 506 | var canPerformAction: Bool { 507 | if let colorWell { 508 | if colorWell.secondaryAction != nil && colorWell.secondaryTarget != nil { 509 | // we have a secondary action and a target to perform it; this 510 | // gets priority over the popover configuration, so no need to 511 | // perform further checks 512 | return true 513 | } 514 | return !colorWell.swatchColors.isEmpty 515 | } 516 | return false 517 | } 518 | 519 | override var draggingInformation: DraggingInformation { 520 | didSet { 521 | // make sure the caret disappears when dragging starts 522 | if draggingInformation.isDragging { 523 | state = .default 524 | } 525 | } 526 | } 527 | 528 | override class func performAction(for segment: CWColorWellSegment) -> Bool { 529 | guard let colorWell = segment.colorWell else { 530 | return false 531 | } 532 | 533 | if 534 | let segment = segment as? Self, 535 | !segment.canPerformAction || NSEvent.modifierFlags.contains(.shift) 536 | { 537 | // can't perform the standard action; treat like a toggle segment 538 | return CWToggleSegment.performAction(for: segment) 539 | } 540 | 541 | if 542 | let secondaryAction = colorWell.secondaryAction, 543 | let secondaryTarget = colorWell.secondaryTarget 544 | { 545 | // secondary action takes priority over showing the popover 546 | return NSApp.sendAction(secondaryAction, to: secondaryTarget, from: colorWell) 547 | } 548 | 549 | return colorWell.makeAndShowPopover(relativeTo: segment) 550 | } 551 | 552 | private func drawBorder() { 553 | guard let context = NSGraphicsContext.current else { 554 | return 555 | } 556 | 557 | context.saveGraphicsState() 558 | defer { 559 | context.restoreGraphicsState() 560 | } 561 | 562 | let lineWidth: CGFloat = 0.5 563 | let insetRect = bounds.insetBy(dx: lineWidth / 2, dy: lineWidth / 2) 564 | segmentPath(insetRect).stroke(with: borderColor, lineWidth: lineWidth) 565 | } 566 | 567 | private func drawCaret() { 568 | guard 569 | canPerformAction, 570 | let context = NSGraphicsContext.current 571 | else { 572 | return 573 | } 574 | 575 | // caret needs to be drawn differently depending on the control size; 576 | // these values aren't based on any real logic, just what looks good 577 | let (bgBounds, caretBounds, lineWidth): (NSRect, NSRect, CGFloat) = { 578 | let (bgDimension, lineWidth, sizeFactor, padding): (CGFloat, CGFloat, CGFloat, CGFloat) = { 579 | // lazy declarations prevent reallocation on first reassignment 580 | lazy var bgDimension: CGFloat = 12.0 581 | lazy var lineWidth: CGFloat = 1.5 582 | lazy var sizeFactor: CGFloat = 2.0 583 | lazy var padding: CGFloat = 4.0 584 | 585 | switch colorWell?.controlSize { 586 | case .large, .regular, .none: 587 | break // no change 588 | case .small: 589 | bgDimension = 10.0 590 | lineWidth = 1.33 591 | sizeFactor = 1.85 592 | padding = 3.0 593 | case .mini: 594 | bgDimension = 9.0 595 | lineWidth = 1.25 596 | sizeFactor = 1.75 597 | padding = 2.0 598 | @unknown default: 599 | break 600 | } 601 | 602 | return (bgDimension, lineWidth, sizeFactor, padding) 603 | }() 604 | 605 | let bgBounds = NSRect( 606 | x: bounds.maxX - bgDimension - padding, 607 | y: bounds.midY - bgDimension / 2, 608 | width: bgDimension, 609 | height: bgDimension 610 | ) 611 | let caretBounds: NSRect = { 612 | let dimension = (bgDimension - lineWidth) / sizeFactor 613 | let size = NSSize( 614 | width: dimension, 615 | height: dimension / 2 616 | ) 617 | let origin = NSPoint( 618 | x: bgBounds.midX - (size.width / 2), 619 | y: bgBounds.midY - (size.height / 2) - (lineWidth / 4) 620 | ) 621 | return NSRect(origin: origin, size: size) 622 | }() 623 | 624 | return (bgBounds, caretBounds, lineWidth) 625 | }() 626 | 627 | context.saveGraphicsState() 628 | defer { 629 | context.restoreGraphicsState() 630 | } 631 | 632 | NSColor(white: 0, alpha: 0.25).setFill() 633 | NSBezierPath(ovalIn: bgBounds).fill() 634 | 635 | let caretPath = Path(elements: [ 636 | .move(to: NSPoint(x: caretBounds.minX, y: caretBounds.maxY)), 637 | .line(to: NSPoint(x: caretBounds.midX, y: caretBounds.minY)), 638 | .line(to: NSPoint(x: caretBounds.maxX, y: caretBounds.maxY)), 639 | ]).nsBezierPath() 640 | 641 | caretPath.lineCapStyle = .round 642 | caretPath.lineJoinStyle = .round 643 | caretPath.lineWidth = lineWidth 644 | 645 | NSColor.white.setStroke() 646 | caretPath.stroke() 647 | } 648 | 649 | override func draw(_ dirtyRect: NSRect) { 650 | super.draw(dirtyRect) 651 | drawBorder() 652 | if state == .hover { 653 | drawCaret() 654 | } 655 | } 656 | 657 | override func mouseEntered(with event: NSEvent) { 658 | super.mouseEntered(with: event) 659 | guard isEnabled else { 660 | return 661 | } 662 | state = .hover 663 | } 664 | 665 | override func mouseExited(with event: NSEvent) { 666 | super.mouseExited(with: event) 667 | guard isEnabled else { 668 | return 669 | } 670 | state = .default 671 | } 672 | 673 | override func needsDisplayOnStateChange(_ state: State) -> Bool { 674 | switch state { 675 | case .hover, .default: true 676 | case .highlight, .pressed: false 677 | } 678 | } 679 | 680 | override func updateTrackingAreas() { 681 | super.updateTrackingAreas() 682 | if let mouseEnterExitTrackingArea { 683 | removeTrackingArea(mouseEnterExitTrackingArea) 684 | } 685 | let mouseEnterExitTrackingArea = NSTrackingArea( 686 | rect: bounds, 687 | options: [ 688 | .activeInKeyWindow, 689 | .mouseEnteredAndExited, 690 | ], 691 | owner: self 692 | ) 693 | addTrackingArea(mouseEnterExitTrackingArea) 694 | self.mouseEnterExitTrackingArea = mouseEnterExitTrackingArea 695 | } 696 | } 697 | 698 | // MARK: - CWSinglePullDownSwatchSegment 699 | 700 | /// A pull down swatch segment that fills its color well. 701 | class CWSinglePullDownSwatchSegment: CWPullDownSwatchSegment { 702 | override class var edge: Edge? { nil } 703 | 704 | override var borderColor: NSColor { .placeholderTextColor } 705 | } 706 | 707 | // MARK: - CWPartialPullDownSwatchSegment 708 | 709 | /// A pull down swatch segment that does not fill its color well. 710 | class CWPartialPullDownSwatchSegment: CWPullDownSwatchSegment { 711 | override class var edge: Edge? { .leading } 712 | } 713 | 714 | // MARK: - CWToggleSegment 715 | 716 | /// A segment that toggles the system color panel when pressed. 717 | class CWToggleSegment: CWColorWellSegment { 718 | private enum Images { 719 | static let defaultImage: NSImage = { 720 | // force unwrap is okay here, as the image is an AppKit builtin 721 | // swiftlint:disable:next force_unwrapping 722 | let image = NSImage(named: NSImage.touchBarColorPickerFillName)! 723 | 724 | let minDimension = min(image.size.width, image.size.height) 725 | let croppedSize = NSSize(width: minDimension, height: minDimension) 726 | let croppedRect = NSRect(origin: .zero, size: croppedSize) 727 | .centered(in: NSRect(origin: .zero, size: image.size)) 728 | 729 | return NSImage(size: croppedSize, flipped: false) { bounds in 730 | image.draw(in: bounds, from: croppedRect, operation: .copy, fraction: 1) 731 | return true 732 | } 733 | }() 734 | 735 | static let enabledImageForDarkAppearance = defaultImage.tinted(to: .white, fraction: 1 / 3) 736 | 737 | static let enabledImageForLightAppearance = defaultImage.tinted(to: .black, fraction: 1 / 5) 738 | 739 | static let disabledImageForDarkAppearance = defaultImage.tinted(to: .gray, fraction: 1 / 3).withOpacity(0.5) 740 | 741 | static let disabledImageForLightAppearance = defaultImage.tinted(to: .gray, fraction: 1 / 5).withOpacity(0.5) 742 | } 743 | 744 | static let widthConstant: CGFloat = 20 745 | 746 | override class var edge: Edge? { .trailing } 747 | 748 | override var rawColor: NSColor { 749 | switch state { 750 | case .highlight: 751 | return highlightedSegmentColor 752 | case .pressed: 753 | return selectedSegmentColor 754 | default: 755 | return segmentColor 756 | } 757 | } 758 | 759 | override init(colorWell: CWColorWell) { 760 | super.init(colorWell: colorWell) 761 | self.translatesAutoresizingMaskIntoConstraints = false 762 | self.widthAnchor.constraint(equalToConstant: Self.widthConstant).isActive = true 763 | } 764 | 765 | override class func performAction(for segment: CWColorWellSegment) -> Bool { 766 | guard let colorWell = segment.colorWell else { 767 | return false 768 | } 769 | if colorWell.isActive { 770 | colorWell.deactivate() 771 | } else { 772 | colorWell.activateAutoExclusive() 773 | } 774 | return true 775 | } 776 | 777 | override func draw(_ dirtyRect: NSRect) { 778 | super.draw(dirtyRect) 779 | 780 | guard 781 | let context = NSGraphicsContext.current, 782 | let colorWell 783 | else { 784 | return 785 | } 786 | 787 | context.saveGraphicsState() 788 | defer { 789 | context.restoreGraphicsState() 790 | } 791 | 792 | let imageRect: NSRect = { 793 | let (pad, width, height) = (5.5, bounds.width, bounds.height) 794 | var dimension = min(max(height - pad * 2, width - pad), width - 1) 795 | switch colorWell.controlSize { 796 | case .large, .regular: 797 | break // no change 798 | case .small: 799 | dimension -= 3 800 | case .mini: 801 | dimension -= 4 802 | @unknown default: 803 | break 804 | } 805 | return NSRect( 806 | x: bounds.midX - dimension / 2, 807 | y: bounds.midY - dimension / 2, 808 | width: dimension, 809 | height: dimension 810 | ) 811 | }() 812 | 813 | let image: NSImage = { 814 | switch ColorScheme.current { 815 | case .light where isEnabled: 816 | if state == .highlight { 817 | return Images.enabledImageForLightAppearance 818 | } 819 | return Images.defaultImage 820 | case .light: 821 | return Images.disabledImageForLightAppearance 822 | case .dark where isEnabled: 823 | if state == .highlight { 824 | return Images.enabledImageForDarkAppearance 825 | } 826 | return Images.defaultImage 827 | case .dark: 828 | return Images.disabledImageForDarkAppearance 829 | } 830 | }() 831 | 832 | NSBezierPath(ovalIn: imageRect).addClip() 833 | image.draw(in: imageRect) 834 | } 835 | 836 | override func mouseDragged(with event: NSEvent) { 837 | super.mouseDragged(with: event) 838 | guard isEnabled else { 839 | return 840 | } 841 | if frameConvertedToWindow.contains(event.locationInWindow) { 842 | state = .highlight 843 | } else if isActive { 844 | state = .pressed 845 | } else { 846 | state = .default 847 | } 848 | } 849 | 850 | override func updateForCurrentActiveState(_ isActive: Bool) { 851 | state = isActive ? .pressed : .default 852 | } 853 | 854 | override func needsDisplayOnStateChange(_ state: State) -> Bool { 855 | switch state { 856 | case .highlight, .pressed, .default: 857 | return true 858 | case .hover: 859 | return false 860 | } 861 | } 862 | 863 | override func accessibilityLabel() -> String? { 864 | return "color picker" 865 | } 866 | } 867 | --------------------------------------------------------------------------------