├── .github └── workflows │ ├── breaking.yml │ ├── swiftlint.yml │ └── test.yml ├── .gitignore ├── .spi.yml ├── .swiftlint.yml ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── ColorWell │ ├── Documentation.docc │ ├── Cocoa │ │ ├── Collections │ │ │ └── ColorWell-swift.class.Deprecated.md │ │ └── Extensions │ │ │ ├── ColorWell-swift.class.md │ │ │ └── ColorWell.Style │ │ │ ├── ColorWell.Style.init(rawValue:).md │ │ │ └── ColorWell.Style.md │ ├── ColorWell.md │ ├── Resources │ │ ├── color-well-with-popover-dark.png │ │ ├── color-well-with-popover-light.png │ │ ├── design-comparison-dark.png │ │ ├── design-comparison-light.png │ │ ├── grid-view.png │ │ ├── swatches-style@2x.png │ │ └── swatches-style~dark@2x.png │ └── SwiftUI │ │ ├── Collections │ │ └── ColorWellView.Deprecated.md │ │ └── Extensions │ │ ├── ColorWellStyle │ │ ├── ColorWellStyle.md │ │ ├── ExpandedColorWellStyle.md │ │ ├── PanelColorWellStyle.md │ │ ├── StandardColorWellStyle.md │ │ └── SwatchesColorWellStyle.md │ │ └── ColorWellView.md │ ├── Utilities │ ├── ActionButton.swift │ ├── Cache.swift │ ├── ColorComponents.swift │ ├── ConstructablePath.swift │ ├── Deprecated.swift │ ├── DrawingStyle.swift │ ├── Extensions.swift │ └── Storage.swift │ └── Views │ ├── Cocoa │ ├── ColorWell.swift │ ├── ColorWellBaseView.swift │ ├── ColorWellLayoutView.swift │ ├── Popover │ │ ├── ColorSwatch.swift │ │ ├── ColorWellPopover.swift │ │ ├── ColorWellPopoverContainerView.swift │ │ ├── ColorWellPopoverContext.swift │ │ ├── ColorWellPopoverLayoutView.swift │ │ ├── ColorWellPopoverSwatchView.swift │ │ └── ColorWellPopoverViewController.swift │ ├── Segments │ │ ├── ColorWellBorderedSwatchSegment.swift │ │ ├── ColorWellPullDownSwatchSegment.swift │ │ ├── ColorWellSegment.swift │ │ ├── ColorWellSwatchSegment.swift │ │ └── ColorWellToggleSegment.swift │ └── Style.swift │ └── SwiftUI │ ├── ColorWellConfiguration.swift │ ├── ColorWellRepresentable.swift │ ├── ColorWellStyle.swift │ ├── ColorWellView.swift │ ├── EnvironmentValues.swift │ └── ViewModifiers.swift └── Tests └── ColorWellTests └── ColorWellTests.swift /.github/workflows/breaking.yml: -------------------------------------------------------------------------------- 1 | name: Diagnose Breaking Changes 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - ".github/workflows/breaking.yml" 9 | - "**/*.swift" 10 | pull_request: 11 | paths: 12 | - ".github/workflows/breaking.yml" 13 | - "**/*.swift" 14 | 15 | jobs: 16 | diagnose: 17 | if: '!github.event.pull_request.merged' 18 | runs-on: macos-latest 19 | steps: 20 | - name: Get Latest Release 21 | id: latest_release 22 | uses: pozetroninc/github-action-get-latest-release@v0.7.0 23 | with: 24 | repository: ${{ github.repository }} 25 | token: ${{ secrets.GITHUB_TOKEN }} 26 | excludes: prerelease, draft 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | - name: Fetch Tags 30 | run: git fetch --tags 31 | - name: Diagnose 32 | run: | 33 | swift package diagnose-api-breaking-changes ${{ steps.latest_release.outputs.release }} 34 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | - name: Check Swift Version 22 | run: swift --version 23 | - name: Run Tests 24 | run: swift test -v 25 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [ColorWell] 5 | -------------------------------------------------------------------------------- /.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 | 14 | opt_in_rules: 15 | - closure_end_indentation 16 | - closure_spacing 17 | - collection_alignment 18 | - convenience_type 19 | - discouraged_object_literal 20 | - empty_count 21 | - fatal_error_message 22 | - file_header 23 | - force_unwrapping 24 | - implicitly_unwrapped_optional 25 | - indentation_width 26 | - literal_expression_end_indentation 27 | - lower_acl_than_parent 28 | - modifier_order 29 | - multiline_arguments 30 | - multiline_arguments_brackets 31 | - multiline_literal_brackets 32 | - multiline_parameters 33 | - multiline_parameters_brackets 34 | - operator_usage_whitespace 35 | - period_spacing 36 | - prefer_self_in_static_references 37 | - unavailable_function 38 | - vertical_parameter_alignment_on_call 39 | - vertical_whitespace_closing_braces 40 | - yoda_condition 41 | 42 | custom_rules: 43 | objc_dynamic: 44 | name: "@objc dynamic" 45 | message: "`dynamic` modifier should immediately follow `@objc` attribute" 46 | regex: '@objc\b(\(\w*\))?+\s*(\S+|\v+\S*)\s*\bdynamic' 47 | match_kinds: attribute.builtin 48 | prefer_spaces_over_tabs: 49 | name: Prefer Spaces Over Tabs 50 | message: Indentation should use 4 spaces per indentation level instead of tabs 51 | regex: ^\t 52 | 53 | file_header: 54 | required_pattern: | 55 | // 56 | // SWIFTLINT_CURRENT_FILENAME 57 | // ColorWell 58 | // 59 | 60 | modifier_order: 61 | preferred_modifier_order: 62 | - acl 63 | - setterACL 64 | - override 65 | - mutators 66 | - lazy 67 | - final 68 | - required 69 | - convenience 70 | - typeMethods 71 | - owned 72 | 73 | trailing_comma: 74 | mandatory_comma: true 75 | 76 | type_name: 77 | allowed_symbols: ["_"] 78 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ColorWell", 7 | platforms: [ 8 | .macOS(.v10_13), 9 | ], 10 | products: [ 11 | .library( 12 | name: "ColorWell", 13 | targets: ["ColorWell"] 14 | ), 15 | ], 16 | targets: [ 17 | .target( 18 | name: "ColorWell", 19 | dependencies: [] 20 | ), 21 | .testTarget( 22 | name: "ColorWellTests", 23 | dependencies: ["ColorWell"] 24 | ), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Notice 2 | 3 | Due to a number of design problems and increasing repository bloat, this package has been deprecated and replaced, and this repository will be archived. Please use its successor [ColorWellKit](https://github.com/jordanbaird/ColorWellKit) instead. 4 | 5 | # ColorWell 6 | 7 | [![Continuous Integration][ci-badge]](https://github.com/jordanbaird/ColorWell/actions/workflows/test.yml) 8 | [![Release][release-badge]](https://github.com/jordanbaird/ColorWell/releases/latest) 9 | [![Swift Versions][versions-badge]](https://swiftpackageindex.com/jordanbaird/ColorWell) 10 | [![Docs][docs-badge]](https://swiftpackageindex.com/jordanbaird/ColorWell/documentation) 11 | [![License][license-badge]](LICENSE) 12 | 13 | A versatile alternative to `NSColorWell` for Cocoa and `ColorPicker` for SwiftUI. 14 | 15 |
16 | 17 | 18 |
19 | 20 | ColorWell is designed to mimic the appearance and behavior of the new color well design in macOS 13 Ventura, for those who want to use the new design on older operating systems. While the goal is for ColorWell to look and behave in a similar way to Apple's design, it is not 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. However, in practice, there are very few notable differences: 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | ## Install 31 | 32 | Add the following dependency to your `Package.swift` file: 33 | 34 | ```swift 35 | .package(url: "https://github.com/jordanbaird/ColorWell", from: "0.2.2") 36 | ``` 37 | 38 | ## Usage 39 | 40 | [Read the full documentation here](https://swiftpackageindex.com/jordanbaird/ColorWell/documentation) 41 | 42 | ### SwiftUI 43 | 44 | Create a `ColorWellView` 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 action. 45 | 46 | ```swift 47 | import SwiftUI 48 | import ColorWell 49 | 50 | struct ContentView: View { 51 | @Binding var fontColor: Color 52 | 53 | var body: some View { 54 | VStack { 55 | ColorWellView("Font Color", color: fontColor, action: updateFontColor) 56 | .colorWellStyle(.expanded) 57 | 58 | MyCustomTextEditor(fontColor: $fontColor) 59 | } 60 | } 61 | 62 | private func updateFontColor(_ color: Color) { 63 | fontColor = color 64 | } 65 | } 66 | ``` 67 | 68 | ### Cocoa 69 | 70 | Create a `ColorWell` using one of the available initializers. Observe color changes using the `onColorChange(perform:)` method. 71 | 72 | ```swift 73 | import Cocoa 74 | import ColorWell 75 | 76 | class ContentViewController: NSViewController { 77 | let colorWell: ColorWell 78 | let textEditor: MyCustomNSTextEditor 79 | 80 | init(fontColor: NSColor) { 81 | self.colorWell = ColorWell(color: fontColor) 82 | self.textEditor = MyCustomNSTextEditor(fontColor: fontColor) 83 | 84 | super.init(nibName: "ContentView", bundle: Bundle(for: Self.self)) 85 | 86 | // Set the style 87 | colorWell.style = .expanded 88 | 89 | // Add a change handler 90 | colorWell.onColorChange { newColor in 91 | self.textEditor.fontColor = newColor 92 | } 93 | } 94 | 95 | override func viewDidLoad() { 96 | super.viewDidLoad() 97 | 98 | view.addSubview(colorWell) 99 | view.addSubview(textEditor) 100 | 101 | // Layout the views, perform setup work, etc. 102 | } 103 | } 104 | ``` 105 | 106 | ## License 107 | 108 | ColorWell is available under the [MIT license](LICENSE). 109 | 110 | [ci-badge]: https://img.shields.io/github/actions/workflow/status/jordanbaird/ColorWell/test.yml?branch=main&style=flat-square 111 | [release-badge]: https://img.shields.io/github/v/release/jordanbaird/ColorWell?style=flat-square 112 | [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%2FColorWell%2Fbadge%3Ftype%3Dswift-versions&style=flat-square 113 | [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 114 | [license-badge]: https://img.shields.io/github/license/jordanbaird/ColorWell?style=flat-square 115 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/Cocoa/Collections/ColorWell-swift.class.Deprecated.md: -------------------------------------------------------------------------------- 1 | # Deprecated Symbols 2 | 3 | Review unsupported symbols and their replacements. 4 | 5 | ## Topics 6 | 7 | ### Initializers 8 | 9 | - ``ColorWell/ColorWell/init(ciColor:)`` 10 | 11 | ### Instance Properties 12 | 13 | - ``ColorWell/ColorWell/colorPanel`` 14 | - ``ColorWell/ColorWell/showsAlpha`` 15 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/Cocoa/Extensions/ColorWell-swift.class.md: -------------------------------------------------------------------------------- 1 | # ``ColorWell/ColorWell`` 2 | 3 | ## Topics 4 | 5 | ### Designated Initializers 6 | 7 | - ``init(frame:color:style:)`` 8 | - ``init(coder:)`` 9 | 10 | ### Convenience Initializers 11 | 12 | - ``init(frame:)`` 13 | - ``init(color:)`` 14 | - ``init(style:)`` 15 | - ``init()`` 16 | - ``init(frame:color:)`` 17 | - ``init(cgColor:)`` 18 | - ``init(coreImageColor:)`` 19 | - ``init(_:)`` 20 | 21 | ### Instance Properties 22 | 23 | - ``allowsMultipleSelection`` 24 | - ``color`` 25 | - ``isActive`` 26 | - ``isEnabled`` 27 | - ``style-swift.property`` 28 | - ``swatchColors`` 29 | 30 | ### Instance Methods 31 | 32 | - ``activate(exclusive:)`` 33 | - ``deactivate()`` 34 | - ``onColorChange(perform:)`` 35 | 36 | ### Supporting Types 37 | 38 | - ``Style-swift.enum`` 39 | 40 | ### Deprecated 41 | 42 | - 43 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/Cocoa/Extensions/ColorWell.Style/ColorWell.Style.init(rawValue:).md: -------------------------------------------------------------------------------- 1 | # ``ColorWell/ColorWell/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.expanded)" 10 | 11 | print(Style(rawValue: 1)) 12 | // Prints "Optional(Style.swatches)" 13 | 14 | print(Style(rawValue: 2)) 15 | // Prints "Optional(Style.colorPanel)" 16 | 17 | print(Style(rawValue: 3)) 18 | // Prints "nil" 19 | ``` 20 | 21 | - Parameter rawValue: The raw value to use for the new instance. 22 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/Cocoa/Extensions/ColorWell.Style/ColorWell.Style.md: -------------------------------------------------------------------------------- 1 | # ``ColorWell/ColorWell/Style-swift.enum`` 2 | 3 | ## Topics 4 | 5 | ### Getting the available styles 6 | 7 | - ``expanded`` 8 | - ``swatches`` 9 | - ``colorPanel`` 10 | 11 | ### Creating a style from a raw value 12 | 13 | - ``init(rawValue:)`` 14 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/ColorWell.md: -------------------------------------------------------------------------------- 1 | # ``ColorWell`` 2 | 3 | A versatile alternative to `NSColorWell` for Cocoa and `ColorPicker` for SwiftUI. 4 | 5 | ## Overview 6 | 7 | ColorWell is designed to mimic the appearance and behavior of the new color well design in macOS 13 Ventura, for those who want to use the new design on older operating systems. 8 | 9 | | Light mode | Dark mode | 10 | | --------------- | -------------- | 11 | | ![][light-mode] | ![][dark-mode] | 12 | 13 | ## SwiftUI 14 | 15 | Create a ``ColorWellView`` 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 action. 16 | 17 | ```swift 18 | import SwiftUI 19 | import ColorWell 20 | 21 | struct ContentView: View { 22 | @Binding var fontColor: Color 23 | 24 | var body: some View { 25 | VStack { 26 | ColorWellView("Font Color", color: fontColor, action: updateFontColor) 27 | .colorWellStyle(.expanded) 28 | 29 | MyCustomTextEditor(fontColor: $fontColor) 30 | } 31 | } 32 | 33 | private func updateFontColor(_ color: Color) { 34 | fontColor = color 35 | } 36 | } 37 | ``` 38 | 39 | ## Cocoa 40 | 41 | Create a ``ColorWell/ColorWell`` using one of the available initializers. Observe color changes using the ``ColorWell/ColorWell/onColorChange(perform:)`` method. 42 | 43 | ```swift 44 | import Cocoa 45 | import ColorWell 46 | 47 | class ContentViewController: NSViewController { 48 | let colorWell: ColorWell 49 | let textEditor: MyCustomNSTextEditor 50 | 51 | init(fontColor: NSColor) { 52 | self.colorWell = ColorWell(color: fontColor) 53 | self.textEditor = MyCustomNSTextEditor(fontColor: fontColor) 54 | 55 | super.init(nibName: "ContentView", bundle: Bundle(for: Self.self)) 56 | 57 | // Set the style 58 | colorWell.style = .expanded 59 | 60 | // Add a change handler 61 | colorWell.onColorChange { newColor in 62 | self.textEditor.fontColor = newColor 63 | } 64 | } 65 | 66 | override func viewDidLoad() { 67 | super.viewDidLoad() 68 | 69 | view.addSubview(colorWell) 70 | view.addSubview(textEditor) 71 | 72 | // Layout the views, perform setup work, etc. 73 | } 74 | } 75 | ``` 76 | 77 | [light-mode]: color-well-with-popover-light.png 78 | [dark-mode]: color-well-with-popover-dark.png 79 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/Resources/color-well-with-popover-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWell/5063ae4afcf6a2c417e7631eec032bca844fa936/Sources/ColorWell/Documentation.docc/Resources/color-well-with-popover-dark.png -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/Resources/color-well-with-popover-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWell/5063ae4afcf6a2c417e7631eec032bca844fa936/Sources/ColorWell/Documentation.docc/Resources/color-well-with-popover-light.png -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/Resources/design-comparison-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWell/5063ae4afcf6a2c417e7631eec032bca844fa936/Sources/ColorWell/Documentation.docc/Resources/design-comparison-dark.png -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/Resources/design-comparison-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWell/5063ae4afcf6a2c417e7631eec032bca844fa936/Sources/ColorWell/Documentation.docc/Resources/design-comparison-light.png -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/Resources/grid-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWell/5063ae4afcf6a2c417e7631eec032bca844fa936/Sources/ColorWell/Documentation.docc/Resources/grid-view.png -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/Resources/swatches-style@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWell/5063ae4afcf6a2c417e7631eec032bca844fa936/Sources/ColorWell/Documentation.docc/Resources/swatches-style@2x.png -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/Resources/swatches-style~dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/ColorWell/5063ae4afcf6a2c417e7631eec032bca844fa936/Sources/ColorWell/Documentation.docc/Resources/swatches-style~dark@2x.png -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/SwiftUI/Collections/ColorWellView.Deprecated.md: -------------------------------------------------------------------------------- 1 | # Deprecated Symbols 2 | 3 | Review unsupported symbols and their replacements. 4 | 5 | ## Topics 6 | 7 | ### Initializers 8 | 9 | - ``ColorWell/ColorWellView/init(color:)`` 10 | - ``ColorWell/ColorWellView/init(cgColor:)`` 11 | - ``ColorWell/ColorWellView/init(color:showsAlpha:)`` 12 | - ``ColorWell/ColorWellView/init(cgColor:showsAlpha:)`` 13 | - ``ColorWell/ColorWellView/init(color:action:)`` 14 | - ``ColorWell/ColorWellView/init(cgColor:action:)`` 15 | - ``ColorWell/ColorWellView/init(color:showsAlpha:action:)`` 16 | - ``ColorWell/ColorWellView/init(cgColor:showsAlpha:action:)`` 17 | - ``ColorWell/ColorWellView/init(color:label:)`` 18 | - ``ColorWell/ColorWellView/init(cgColor:label:)`` 19 | - ``ColorWell/ColorWellView/init(showsAlpha:color:label:)`` 20 | - ``ColorWell/ColorWellView/init(showsAlpha:cgColor:label:)`` 21 | - ``ColorWell/ColorWellView/init(label:action:)`` 22 | - ``ColorWell/ColorWellView/init(showsAlpha:label:action:)`` 23 | - ``ColorWell/ColorWellView/init(color:label:action:)`` 24 | - ``ColorWell/ColorWellView/init(cgColor:label:action:)`` 25 | - ``ColorWell/ColorWellView/init(showsAlpha:color:label:action:)`` 26 | - ``ColorWell/ColorWellView/init(showsAlpha:cgColor:label:action:)`` 27 | - ``ColorWell/ColorWellView/init(_:color:)-9vor5`` 28 | - ``ColorWell/ColorWellView/init(_:color:)-x43r`` 29 | - ``ColorWell/ColorWellView/init(_:cgColor:)-4ptzb`` 30 | - ``ColorWell/ColorWellView/init(_:cgColor:)-6sdq1`` 31 | - ``ColorWell/ColorWellView/init(_:color:showsAlpha:)-9n2ku`` 32 | - ``ColorWell/ColorWellView/init(_:color:showsAlpha:)-8w0wq`` 33 | - ``ColorWell/ColorWellView/init(_:cgColor:showsAlpha:)-669gg`` 34 | - ``ColorWell/ColorWellView/init(_:cgColor:showsAlpha:)-7u0cq`` 35 | - ``ColorWell/ColorWellView/init(_:action:)-k9g0`` 36 | - ``ColorWell/ColorWellView/init(_:action:)-9c6rx`` 37 | - ``ColorWell/ColorWellView/init(_:showsAlpha:action:)-8jhlo`` 38 | - ``ColorWell/ColorWellView/init(_:showsAlpha:action:)-2v1oy`` 39 | - ``ColorWell/ColorWellView/init(_:color:action:)-8ghst`` 40 | - ``ColorWell/ColorWellView/init(_:color:action:)-3s0o1`` 41 | - ``ColorWell/ColorWellView/init(_:cgColor:action:)-62zw9`` 42 | - ``ColorWell/ColorWellView/init(_:cgColor:action:)-3tub`` 43 | - ``ColorWell/ColorWellView/init(_:color:showsAlpha:action:)-68zal`` 44 | - ``ColorWell/ColorWellView/init(_:color:showsAlpha:action:)-60wmk`` 45 | - ``ColorWell/ColorWellView/init(_:cgColor:showsAlpha:action:)-8ra6i`` 46 | - ``ColorWell/ColorWellView/init(_:cgColor:showsAlpha:action:)-5v4z6`` 47 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/SwiftUI/Extensions/ColorWellStyle/ColorWellStyle.md: -------------------------------------------------------------------------------- 1 | # ``ColorWell/ColorWellStyle`` 2 | 3 | ## Topics 4 | 5 | ### Types of styles 6 | 7 | - ``ExpandedColorWellStyle`` 8 | - ``SwatchesColorWellStyle`` 9 | - ``StandardColorWellStyle`` 10 | 11 | ### Creating a color well style 12 | 13 | - ``ColorWellStyle/expanded`` 14 | - ``ColorWellStyle/swatches`` 15 | - ``ColorWellStyle/standard`` 16 | 17 | ### Deprecated styles 18 | 19 | - ``PanelColorWellStyle`` 20 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/SwiftUI/Extensions/ColorWellStyle/ExpandedColorWellStyle.md: -------------------------------------------------------------------------------- 1 | # ``ColorWell/ExpandedColorWellStyle`` 2 | 3 | ## Topics 4 | 5 | ### Getting the style 6 | 7 | - ``ColorWellStyle/expanded`` 8 | 9 | ### Creating an expanded color well style 10 | 11 | - ``init()`` 12 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/SwiftUI/Extensions/ColorWellStyle/PanelColorWellStyle.md: -------------------------------------------------------------------------------- 1 | # ``ColorWell/PanelColorWellStyle`` 2 | 3 | ## Topics 4 | 5 | ### Getting the style 6 | 7 | - ``ColorWellStyle/colorPanel`` 8 | 9 | ### Creating a panel color well style 10 | 11 | - ``init()`` 12 | 13 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/SwiftUI/Extensions/ColorWellStyle/StandardColorWellStyle.md: -------------------------------------------------------------------------------- 1 | # ``ColorWell/StandardColorWellStyle`` 2 | 3 | ## Topics 4 | 5 | ### Getting the style 6 | 7 | - ``ColorWellStyle/standard`` 8 | 9 | ### Creating a standard color well style 10 | 11 | - ``init()`` 12 | 13 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/SwiftUI/Extensions/ColorWellStyle/SwatchesColorWellStyle.md: -------------------------------------------------------------------------------- 1 | # ``ColorWell/SwatchesColorWellStyle`` 2 | 3 | ## Topics 4 | 5 | ### Getting the style 6 | 7 | - ``ColorWellStyle/swatches`` 8 | 9 | ### Creating a swatches color well style 10 | 11 | - ``init()`` 12 | 13 | -------------------------------------------------------------------------------- /Sources/ColorWell/Documentation.docc/SwiftUI/Extensions/ColorWellView.md: -------------------------------------------------------------------------------- 1 | # ``ColorWell/ColorWellView`` 2 | 3 | You create a color well view by providing an initial color value and an action to perform when the color changes. The action can be a method, a closure-typed property, or a literal closure. You can provide a label for the color well in the form of a string, a `LocalizedStringKey`, or a custom view. 4 | 5 | ```swift 6 | ColorWellView("Font Color", color: fontColor) { newColor in 7 | fontColor = newColor 8 | } 9 | ``` 10 | 11 | By default, color wells support colors with opacity; to disable opacity support, set the `supportsOpacity` parameter to `false`. 12 | 13 | ### Styling color wells 14 | 15 | You can customize a color well's appearance using one of the standard color well styles, like ``ColorWellStyle/swatches``, and apply the style with the ``colorWellStyle(_:)`` modifier: 16 | 17 | ```swift 18 | HStack { 19 | ColorWellView("Foreground", color: .blue) 20 | ColorWellView("Background", color: .blue) 21 | } 22 | .colorWellStyle(.swatches) 23 | ``` 24 | 25 | If you apply the style to a container view, as in the example above, all the color wells in the container use the style: 26 | 27 | ![Two color wells, both displayed in the swatches style](swatches-style) 28 | 29 | ## Topics 30 | 31 | ### Creating a color well with an initial color 32 | 33 | - ``init(color:supportsOpacity:)`` 34 | - ``init(cgColor:supportsOpacity:)`` 35 | 36 | ### Creating a color well with a color and action 37 | 38 | - ``init(color:supportsOpacity:action:)`` 39 | - ``init(cgColor:supportsOpacity:action:)`` 40 | 41 | ### Creating a color well with a color and label 42 | 43 | - ``init(color:supportsOpacity:label:)`` 44 | - ``init(cgColor:supportsOpacity:label:)`` 45 | - ``init(_:color:supportsOpacity:)-4e9vl`` 46 | - ``init(_:cgColor:supportsOpacity:)-1zr5r`` 47 | - ``init(_:color:supportsOpacity:)-2js4x`` 48 | - ``init(_:cgColor:supportsOpacity:)-91mdm`` 49 | 50 | ### Creating a color well with a label and action 51 | 52 | - ``init(supportsOpacity:label:action:)`` 53 | - ``init(_:supportsOpacity:action:)-4ijj0`` 54 | - ``init(_:supportsOpacity:action:)-1dho9`` 55 | 56 | ### Creating a color well with a color, label, and action 57 | 58 | - ``init(color:supportsOpacity:label:action:)`` 59 | - ``init(cgColor:supportsOpacity:label:action:)`` 60 | - ``init(_:color:supportsOpacity:action:)-7turx`` 61 | - ``init(_:cgColor:supportsOpacity:action:)-78agl`` 62 | - ``init(_:color:supportsOpacity:action:)-6lguj`` 63 | - ``init(_:cgColor:supportsOpacity:action:)-3f573`` 64 | 65 | ### Modifying color wells 66 | 67 | - ``colorWellStyle(_:)`` 68 | - ``swatchColors(_:)`` 69 | - ``onColorChange(perform:)`` 70 | 71 | ### Getting a color well's content view 72 | 73 | - ``body`` 74 | 75 | ### Supporting Types 76 | 77 | - ``ColorWellStyle`` 78 | 79 | ### Deprecated 80 | 81 | - 82 | -------------------------------------------------------------------------------- /Sources/ColorWell/Utilities/ActionButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionButton.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A button that takes a closure for its action. 9 | class ActionButton: NSButton { 10 | private let _action: () -> Void 11 | 12 | init(title: String, action: @escaping () -> Void) { 13 | self._action = action 14 | super.init(frame: .zero) 15 | self.title = title 16 | self.target = self 17 | self.action = #selector(performAction) 18 | } 19 | 20 | @available(*, unavailable) 21 | required init?(coder: NSCoder) { 22 | fatalError("init(coder:) has not been implemented") 23 | } 24 | 25 | @objc private func performAction() { 26 | _action() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/ColorWell/Utilities/Cache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cache.swift 3 | // ColorWell 4 | // 5 | 6 | // MARK: - CacheContext 7 | 8 | /// A private context used to store the value of a cache. 9 | private class CacheContext { 10 | /// The value stored by the context. 11 | var cachedValue: Value 12 | 13 | /// The identifier associated with the context. 14 | var id: ID 15 | 16 | /// The constructor function associated with the context. 17 | var constructor: Constructor 18 | 19 | /// Creates a context with the given value, identifier, and constructor. 20 | init(cachedValue: Value, id: ID, constructor: Constructor) { 21 | self.cachedValue = cachedValue 22 | self.id = id 23 | self.constructor = constructor 24 | } 25 | } 26 | 27 | // MARK: - Cache 28 | 29 | /// A type that caches a value alongside an equatable identifier 30 | /// that can be used to determine whether the value has changed. 31 | struct Cache { 32 | /// The cache's context. 33 | private let context: Context 34 | 35 | /// The value stored by the cache. 36 | var cachedValue: Value { 37 | context.cachedValue 38 | } 39 | 40 | /// Creates a cache with the given value and identifier. 41 | init(_ cachedValue: Value, id: ID) { 42 | context = Context(cachedValue, id: id) 43 | } 44 | 45 | /// Compares the cache's stored identifier with the specified 46 | /// identifier, and, if the two values are different, updates 47 | /// the cached value using the cache's constructor. 48 | func recache(id: ID) { 49 | guard context.id != id else { 50 | return 51 | } 52 | context.id = id 53 | context.cachedValue = context.constructor(id) 54 | } 55 | 56 | /// Updates the constructor that is stored with this cache. 57 | func updateConstructor(_ constructor: @escaping (ID) -> Value) { 58 | context.constructor = constructor 59 | } 60 | } 61 | 62 | // MARK: Cache.Context 63 | extension Cache { 64 | /// The context for the `Cache` type. 65 | private class Context: CacheContext Value> { 66 | /// Creates a context with the given value and identifier. 67 | init(_ cachedValue: Value, id: ID) { 68 | super.init( 69 | cachedValue: cachedValue, 70 | id: id, 71 | constructor: { _ in cachedValue } 72 | ) 73 | } 74 | } 75 | } 76 | 77 | // MARK: - OptionalCache 78 | 79 | /// A type that caches an optional value, and is able to be 80 | /// recached based on whether its value is `nil`. 81 | struct OptionalCache { 82 | /// The cache's context. 83 | private let context: Context 84 | 85 | /// The value stored by the cache. 86 | var cachedValue: Wrapped? { 87 | context.cachedValue 88 | } 89 | 90 | /// Creates a cache with the given value. 91 | init(_ cachedValue: Wrapped? = nil) { 92 | context = Context(cachedValue) 93 | } 94 | 95 | /// Updates the the cached value using the cache's constructor. 96 | func recache() { 97 | if cachedValue == nil { 98 | context.cachedValue = context.constructor() 99 | } 100 | } 101 | 102 | /// Sets the cached value to `nil`. 103 | func clear() { 104 | context.cachedValue = nil 105 | } 106 | 107 | /// Updates the constructor that is stored with this cache. 108 | func updateConstructor(_ constructor: @escaping () -> Wrapped?) { 109 | context.constructor = constructor 110 | } 111 | } 112 | 113 | // MARK: OptionalCache.Context 114 | extension OptionalCache { 115 | /// The context for the `OptionalCache` type. 116 | private class Context: CacheContext Wrapped?> { 117 | /// Creates a context with the given value. 118 | init(_ cachedValue: Wrapped?) { 119 | super.init( 120 | cachedValue: cachedValue, 121 | id: true, 122 | constructor: { cachedValue } 123 | ) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/ColorWell/Utilities/ColorComponents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorComponents.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A type that contains information about the components of a color. 9 | enum ColorComponents { 10 | case rgb(red: Double, green: Double, blue: Double, alpha: Double) 11 | case cmyk(cyan: Double, magenta: Double, yellow: Double, black: Double, alpha: Double) 12 | case grayscale(white: Double, alpha: Double) 13 | case catalog(name: String) 14 | case unknown(color: NSColor) 15 | case deviceN 16 | case indexed 17 | case lab 18 | case pattern 19 | 20 | // MARK: Properties 21 | 22 | /// The name of the color space associated with this instance. 23 | var colorSpaceName: String { 24 | switch self { 25 | case .rgb: 26 | return "rgb" 27 | case .cmyk: 28 | return "cmyk" 29 | case .grayscale: 30 | return "grayscale" 31 | case .catalog: 32 | return "catalog color" 33 | case .unknown: 34 | return "unknown color space" 35 | case .deviceN: 36 | return "deviceN" 37 | case .indexed: 38 | return "indexed" 39 | case .lab: 40 | return "L*a*b*" 41 | case .pattern: 42 | return "pattern image" 43 | } 44 | } 45 | 46 | /// The raw components extracted from this instance. 47 | var extractedComponents: [Any] { 48 | switch self { 49 | case .rgb(let red, let green, let blue, let alpha): 50 | return [red, green, blue, alpha] 51 | case .cmyk(let cyan, let magenta, let yellow, let black, let alpha): 52 | return [cyan, magenta, yellow, black, alpha] 53 | case .grayscale(let white, let alpha): 54 | return [white, alpha] 55 | case .catalog(let name): 56 | return [name] 57 | case .unknown(let color): 58 | guard color.type == .componentBased else { 59 | return [String(describing: color)] 60 | } 61 | 62 | var components = [CGFloat](repeating: 0, count: color.numberOfComponents) 63 | color.getComponents(&components) 64 | 65 | return components.map { component in 66 | Double(component) 67 | } 68 | default: 69 | return [] 70 | } 71 | } 72 | 73 | /// String representations of the components extracted from this instance. 74 | var extractedComponentStrings: [String] { 75 | let formatter = NumberFormatter() 76 | formatter.minimumIntegerDigits = 1 77 | formatter.minimumFractionDigits = 0 78 | formatter.maximumFractionDigits = 6 79 | 80 | return extractedComponents.compactMap { component in 81 | if let component = component as? Double { 82 | return formatter.string(for: component) 83 | } 84 | return String(describing: component) 85 | } 86 | } 87 | 88 | // MARK: Initializers 89 | 90 | /// Creates an instance from the specified color. 91 | init(color: NSColor) { 92 | switch color.type { 93 | case .componentBased: 94 | switch color.colorSpace.colorSpaceModel { 95 | case .rgb: 96 | self = .rgb( 97 | red: color.redComponent, 98 | green: color.greenComponent, 99 | blue: color.blueComponent, 100 | alpha: color.alphaComponent 101 | ) 102 | case .cmyk: 103 | self = .cmyk( 104 | cyan: color.cyanComponent, 105 | magenta: color.magentaComponent, 106 | yellow: color.yellowComponent, 107 | black: color.blackComponent, 108 | alpha: color.alphaComponent 109 | ) 110 | case .gray: 111 | self = .grayscale( 112 | white: color.whiteComponent, 113 | alpha: color.alphaComponent 114 | ) 115 | case .deviceN: 116 | self = .deviceN 117 | case .indexed: 118 | self = .indexed 119 | case .lab: 120 | self = .lab 121 | case .patterned: 122 | self = .pattern 123 | case .unknown: 124 | self = .unknown(color: color) 125 | @unknown default: 126 | self = .unknown(color: color) 127 | } 128 | case .pattern: 129 | self = .pattern 130 | case .catalog: 131 | self = .catalog(name: color.localizedColorNameComponent) 132 | @unknown default: 133 | self = .unknown(color: color) 134 | } 135 | } 136 | } 137 | 138 | // MARK: ColorComponents: CustomStringConvertible 139 | extension ColorComponents: CustomStringConvertible { 140 | var description: String { 141 | (CollectionOfOne(colorSpaceName) + extractedComponentStrings).joined(separator: " ") 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Sources/ColorWell/Utilities/ConstructablePath.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConstructablePath.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | // MARK: - Corner 9 | 10 | /// A type that represents a corner of a rectangle. 11 | enum Corner { 12 | /// The top left corner of a rectangle. 13 | case topLeft 14 | 15 | /// The top right corner of a rectangle. 16 | case topRight 17 | 18 | /// The bottom left corner of a rectangle. 19 | case bottomLeft 20 | 21 | /// The bottom right corner of a rectangle. 22 | case bottomRight 23 | 24 | /// All corners, ordered for use in color well path construction, starting 25 | /// at the top left and moving clockwise around the color well's border. 26 | static let clockwiseOrder: [Self] = [.topLeft, .topRight, .bottomRight, .bottomLeft] 27 | 28 | /// Returns the point in the given rectangle that corresponds to this corner. 29 | func point(forRect rect: CGRect) -> CGPoint { 30 | switch self { 31 | case .topLeft: 32 | return CGPoint(x: rect.minX, y: rect.maxY) 33 | case .topRight: 34 | return CGPoint(x: rect.maxX, y: rect.maxY) 35 | case .bottomLeft: 36 | return CGPoint(x: rect.minX, y: rect.minY) 37 | case .bottomRight: 38 | return CGPoint(x: rect.maxX, y: rect.minY) 39 | } 40 | } 41 | } 42 | 43 | // MARK: - Side 44 | 45 | /// A type that represents a side of a rectangle. 46 | enum Side { 47 | /// The top side of a rectangle. 48 | case top 49 | 50 | /// The bottom side of a rectangle. 51 | case bottom 52 | 53 | /// The left side of a rectangle. 54 | case left 55 | 56 | /// The right side of a rectangle. 57 | case right 58 | 59 | /// A side that contains no points. 60 | case null 61 | 62 | /// The corners that, when connected by a path, make up this side. 63 | var corners: [Corner] { 64 | switch self { 65 | case .top: 66 | return [.topLeft, .topRight] 67 | case .bottom: 68 | return [.bottomLeft, .bottomRight] 69 | case .left: 70 | return [.topLeft, .bottomLeft] 71 | case .right: 72 | return [.topRight, .bottomRight] 73 | case .null: 74 | return [] 75 | } 76 | } 77 | 78 | /// The side on the opposite end of the rectangle. 79 | var opposite: Self { 80 | switch self { 81 | case .top: 82 | return .bottom 83 | case .bottom: 84 | return .top 85 | case .left: 86 | return .right 87 | case .right: 88 | return .left 89 | case .null: 90 | return .null 91 | } 92 | } 93 | } 94 | 95 | // MARK: - ConstructablePathComponent 96 | 97 | /// A type that represents a component in a constructable path. 98 | enum ConstructablePathComponent { 99 | /// Closes the path. 100 | case close 101 | 102 | /// Moves the path to the given point. 103 | case move(to: CGPoint) 104 | 105 | /// Draws a line in the path from its current point to the given point. 106 | case line(to: CGPoint) 107 | 108 | /// Draws a curved line in the path from its current point to the given 109 | /// point, using the provided control points to determine the curve's shape. 110 | case curve(to: CGPoint, control1: CGPoint, control2: CGPoint) 111 | 112 | /// Draws an arc in the path from its current point, through the given 113 | /// midpoint, to the given endpoint, curving the path according to the 114 | /// specified radius. 115 | case arc(through: CGPoint, to: CGPoint, radius: CGFloat) 116 | 117 | /// A component that nests other components. 118 | /// 119 | /// This case can also be created using array literal syntax. 120 | /// In the following example, `c1` and `c2` are equivalent. 121 | /// 122 | /// ```swift 123 | /// let c1 = ConstructablePathComponent.compound([ 124 | /// .move(to: point1), 125 | /// .line(to: point2), 126 | /// ]) 127 | /// 128 | /// let c2: ConstructablePathComponent = [ 129 | /// .move(to: point1), 130 | /// .line(to: point2), 131 | /// ] 132 | /// 133 | /// print(c1 == c2) // Prints: true 134 | /// ``` 135 | indirect case compound([Self]) 136 | 137 | /// Returns a compound component that constructs a right angle curve around 138 | /// the given corner of the provided rectangle, using the specified radius. 139 | static func rightAngleCurve(around corner: Corner, ofRect rect: CGRect, radius: CGFloat) -> Self { 140 | let mid = corner.point(forRect: rect) 141 | 142 | let start: CGPoint 143 | let end: CGPoint 144 | 145 | switch corner { 146 | case .topLeft: 147 | start = mid.translating(y: -radius) 148 | end = mid.translating(x: radius) 149 | case .topRight: 150 | start = mid.translating(x: -radius) 151 | end = mid.translating(y: -radius) 152 | case .bottomRight: 153 | start = mid.translating(y: radius) 154 | end = mid.translating(x: -radius) 155 | case .bottomLeft: 156 | start = mid.translating(x: radius) 157 | end = mid.translating(y: radius) 158 | } 159 | 160 | return [ 161 | .line(to: start), 162 | .arc(through: mid, to: end, radius: radius), 163 | ] 164 | } 165 | } 166 | 167 | // MARK: ConstructablePathComponent: Equatable 168 | extension ConstructablePathComponent: Equatable { } 169 | 170 | // MARK: ConstructablePathComponent: ExpressibleByArrayLiteral 171 | extension ConstructablePathComponent: ExpressibleByArrayLiteral { 172 | init(arrayLiteral elements: Self...) { 173 | self = .compound(elements) 174 | } 175 | } 176 | 177 | // MARK: - ConstructablePath 178 | 179 | /// A type that can produce a version of itself that can be constructed 180 | /// from `ConstructablePathComponent` values. 181 | protocol ConstructablePath where MutablePath.Constructed == Constructed { 182 | /// The constructed result type of this path type. 183 | associatedtype Constructed: ConstructablePath 184 | 185 | /// A mutable version of this path type that produces the same 186 | /// constructed result. 187 | associatedtype MutablePath: MutableConstructablePath 188 | 189 | /// This path, as its constructed result type. 190 | var asConstructedType: Constructed { get } 191 | 192 | /// Constructs a path from the given components. 193 | /// 194 | /// - Parameter components: The components to use to construct the path. 195 | /// - Returns: A `Constructed`-typed path, constructed using `components`. 196 | static func construct(with components: [ConstructablePathComponent]) -> Constructed 197 | } 198 | 199 | // MARK: ConstructablePath where Constructed == Self 200 | extension ConstructablePath where Constructed == Self { 201 | var asConstructedType: Constructed { self } 202 | } 203 | 204 | // MARK: ConstructablePath Static Methods 205 | extension ConstructablePath { 206 | // Documented in protocol definition. 207 | static func construct(with components: [ConstructablePathComponent]) -> Constructed { 208 | let path = MutablePath() 209 | for component in components { 210 | path.apply(component) 211 | } 212 | return path.asConstructedType 213 | } 214 | 215 | /// Produces a path for a part of a color well. 216 | /// 217 | /// - Parameters: 218 | /// - rect: The rectangle to draw the path in. 219 | /// - corners: The corners that should be drawn with sharp right angles. 220 | /// Corners not provided here will be rounded. 221 | static func colorWellPath(rect: CGRect, squaredCorners corners: [Corner]) -> Constructed { 222 | var components: [ConstructablePathComponent] = Corner.clockwiseOrder.map { corner in 223 | if corners.contains(corner) { 224 | return .line(to: corner.point(forRect: rect)) 225 | } 226 | return .rightAngleCurve(around: corner, ofRect: rect, radius: 5) 227 | } 228 | components.append(.close) 229 | return construct(with: components) 230 | } 231 | 232 | /// Produces a partial path for the specified side of a color well. 233 | /// 234 | /// - Parameters: 235 | /// - rect: The rectangle to draw the path in. 236 | /// - side: The side of `rect` that the path should be drawn in. This 237 | /// parameter provides information about which corners should be rounded 238 | /// and which corners should be drawn with sharp right angles. 239 | static func partialColorWellPath(rect: CGRect, side: Side) -> Constructed { 240 | colorWellPath(rect: rect, squaredCorners: side.opposite.corners) 241 | } 242 | 243 | /// Produces a full path for a color well, that is, a path with 244 | /// none of its corners squared. 245 | /// 246 | /// - Parameter rect: The rectangle to draw the path in. 247 | static func fullColorWellPath(rect: CGRect) -> Constructed { 248 | colorWellPath(rect: rect, squaredCorners: []) 249 | } 250 | } 251 | 252 | // MARK: - MutableConstructablePath 253 | 254 | /// A constructable path type whose instances can be mutated 255 | /// after their creation. 256 | protocol MutableConstructablePath: ConstructablePath { 257 | /// Creates an empty path. 258 | init() 259 | 260 | /// Applies the given path component to this path. 261 | func apply(_ component: ConstructablePathComponent) 262 | } 263 | 264 | // MARK: NSBezierPath: MutableConstructablePath 265 | extension NSBezierPath: MutableConstructablePath { 266 | typealias MutablePath = NSBezierPath 267 | 268 | func apply(_ component: ConstructablePathComponent) { 269 | switch component { 270 | case .close: 271 | close() 272 | case .move(let point): 273 | move(to: point) 274 | case .line(let point): 275 | if isEmpty { 276 | move(to: point) 277 | } else { 278 | line(to: point) 279 | } 280 | case .curve(let point, let control1, let control2): 281 | if isEmpty { 282 | move(to: point) 283 | } else { 284 | curve(to: point, controlPoint1: control1, controlPoint2: control2) 285 | } 286 | case .arc(let midPoint, let endPoint, let radius): 287 | if isEmpty { 288 | move(to: endPoint) 289 | } else { 290 | appendArc(from: midPoint, to: endPoint, radius: radius) 291 | } 292 | case .compound(let components): 293 | for component in components { 294 | apply(component) 295 | } 296 | } 297 | } 298 | } 299 | 300 | // MARK: CGMutablePath: MutableConstructablePath 301 | extension CGMutablePath: MutableConstructablePath { 302 | func apply(_ component: ConstructablePathComponent) { 303 | switch component { 304 | case .close: 305 | closeSubpath() 306 | case .move(let point): 307 | move(to: point) 308 | case .line(let point): 309 | if isEmpty { 310 | move(to: point) 311 | } else { 312 | addLine(to: point) 313 | } 314 | case .curve(let point, let control1, let control2): 315 | if isEmpty { 316 | move(to: point) 317 | } else { 318 | addCurve(to: point, control1: control1, control2: control2) 319 | } 320 | case .arc(let midPoint, let endPoint, let radius): 321 | if isEmpty { 322 | move(to: endPoint) 323 | } else { 324 | addArc(tangent1End: midPoint, tangent2End: endPoint, radius: radius) 325 | } 326 | case .compound(let components): 327 | for component in components { 328 | apply(component) 329 | } 330 | } 331 | } 332 | } 333 | 334 | // MARK: CGPath: ConstructablePath 335 | extension CGPath: ConstructablePath { 336 | typealias MutablePath = CGMutablePath 337 | } 338 | -------------------------------------------------------------------------------- /Sources/ColorWell/Utilities/DrawingStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingStyle.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// Constants that represent the drawing styles used by the 9 | /// system to render colors and assets. 10 | /// 11 | /// These values serve mainly to indicate whether a value of 12 | /// the more complex `NSAppearance` type represents a light 13 | /// or dark appearance. The drawing style that corresponds to 14 | /// the currently active appearance can be retrieved through 15 | /// the `DrawingStyle.current` static property. 16 | enum DrawingStyle { 17 | /// A drawing style that indicates a dark appearance. 18 | case dark 19 | 20 | /// A drawing style that indicates a light appearance. 21 | case light 22 | 23 | // MARK: Static Properties 24 | 25 | /// The drawing style of the current appearance. 26 | static var current: Self { 27 | let currentAppearance: NSAppearance = { 28 | guard #available(macOS 11.0, *) else { 29 | return .current 30 | } 31 | return .currentDrawing() 32 | }() 33 | return currentAppearance.drawingStyle 34 | } 35 | 36 | // MARK: Initializers 37 | 38 | /// Creates a drawing style that corresponds to the specified appearance. 39 | init(appearance: NSAppearance) { 40 | enum LocalCache { 41 | static let systemDarkNames: Set = { 42 | if #available(macOS 10.14, *) { 43 | return [ 44 | .darkAqua, 45 | .vibrantDark, 46 | .accessibilityHighContrastDarkAqua, 47 | .accessibilityHighContrastVibrantDark, 48 | ] 49 | } 50 | return [.vibrantDark] 51 | }() 52 | } 53 | 54 | switch appearance.name { 55 | case let name where ( 56 | LocalCache.systemDarkNames.contains(name) || 57 | name.rawValue.lowercased().contains("dark") 58 | ): 59 | self = .dark 60 | default: 61 | self = .light 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/ColorWell/Utilities/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | // MARK: - CGPoint 9 | 10 | extension CGPoint { 11 | /// Returns a new point resulting from a translation of the current 12 | /// point by the given x and y amounts. 13 | func translating(x: CGFloat = 0, y: CGFloat = 0) -> Self { 14 | applying(CGAffineTransform(translationX: x, y: y)) 15 | } 16 | } 17 | 18 | // MARK: - CGRect 19 | 20 | extension CGRect { 21 | /// Returns a rectangle that is the result of centering the current 22 | /// rectangle within the bounds of another rectangle. 23 | func centered(in otherRect: Self) -> Self { 24 | var new = self 25 | new.origin.x = otherRect.midX - (new.width / 2) 26 | new.origin.y = otherRect.midY - (new.height / 2) 27 | return new 28 | } 29 | } 30 | 31 | // MARK: - CGSize 32 | 33 | extension CGSize { 34 | /// Returns the size that is the result of subtracting the specified 35 | /// edge insets from the current size. 36 | func applying(insets: NSEdgeInsets) -> Self { 37 | Self(width: width - insets.horizontal, height: height - insets.vertical) 38 | } 39 | 40 | /// Returns a new size by applying the given insets to this size. 41 | /// 42 | /// The returned size is calculated according to the following 43 | /// expression, where `w` and `h` represent the width and height 44 | /// of the original size: 45 | /// 46 | /// (w - (dx * 2), h - (dy * 2)) 47 | /// 48 | /// If `dx` and `dy` are positive values, the size is decreased. 49 | /// If they are negative values, the size is increased. 50 | func insetBy(dx: CGFloat, dy: CGFloat) -> Self { 51 | Self(width: width - (dx * 2), height: height - (dy * 2)) 52 | } 53 | } 54 | 55 | // MARK: - Comparable 56 | 57 | extension Comparable { 58 | /// Returns this comparable value, clamped to the given limiting range. 59 | func clamped(to limits: ClosedRange) -> Self { 60 | min(max(self, limits.lowerBound), limits.upperBound) 61 | } 62 | } 63 | 64 | // MARK: - NSAppearance 65 | 66 | extension NSAppearance { 67 | /// The drawing style that corresponds to this appearance. 68 | var drawingStyle: DrawingStyle { 69 | DrawingStyle(appearance: self) 70 | } 71 | } 72 | 73 | // MARK: - NSColor 74 | 75 | extension NSColor { 76 | /// The default fill color for a color well segment. 77 | static var colorWellSegmentColor: NSColor { 78 | switch DrawingStyle.current { 79 | case .dark: 80 | return .selectedControlColor 81 | case .light: 82 | return .controlColor 83 | } 84 | } 85 | 86 | /// The fill color for a highlighted color well segment. 87 | static var highlightedColorWellSegmentColor: NSColor { 88 | switch DrawingStyle.current { 89 | case .dark: 90 | return colorWellSegmentColor.blendedAndClamped(withFraction: 0.2, of: .highlightColor) 91 | case .light: 92 | return colorWellSegmentColor.blendedAndClamped(withFraction: 0.5, of: .selectedControlColor) 93 | } 94 | } 95 | 96 | /// The fill color for a selected color well segment. 97 | static var selectedColorWellSegmentColor: NSColor { 98 | switch DrawingStyle.current { 99 | case .dark: 100 | return colorWellSegmentColor.withAlphaComponent(colorWellSegmentColor.alphaComponent + 0.25) 101 | case .light: 102 | return .selectedControlColor 103 | } 104 | } 105 | 106 | /// Returns the average of this color's red, green, and blue components, 107 | /// approximating the brightness of the color. 108 | var averageBrightness: CGFloat { 109 | guard let sRGB = usingColorSpace(.sRGB) else { 110 | return 0 111 | } 112 | return (sRGB.redComponent + sRGB.greenComponent + sRGB.blueComponent) / 3 113 | } 114 | 115 | /// Creates a color from a hexadecimal string. 116 | convenience init?(hexString: String) { 117 | let hexString = hexString.trimmingCharacters(in: ["#"]).lowercased() 118 | let count = hexString.count 119 | 120 | guard 121 | count >= 6, 122 | count.isMultiple(of: 2) 123 | else { 124 | return nil 125 | } 126 | 127 | let hexArray = hexString.map { String($0) } 128 | 129 | let rString = hexArray[0..<2].joined() 130 | let gString = hexArray[2..<4].joined() 131 | let bString = hexArray[4..<6].joined() 132 | let aString = count == 6 ? "ff" : hexArray[6..<8].joined() 133 | 134 | guard 135 | let rInt = Int(rString, radix: 16), 136 | let gInt = Int(gString, radix: 16), 137 | let bInt = Int(bString, radix: 16), 138 | let aInt = Int(aString, radix: 16) 139 | else { 140 | return nil 141 | } 142 | 143 | let rFloat = CGFloat(rInt) / 255 144 | let gFloat = CGFloat(gInt) / 255 145 | let bFloat = CGFloat(bInt) / 255 146 | let aFloat = CGFloat(aInt) / 255 147 | 148 | self.init(srgbRed: rFloat, green: gFloat, blue: bFloat, alpha: aFloat) 149 | } 150 | 151 | /// Creates a new color object whose component values are a weighted sum 152 | /// of the current and specified color objects. 153 | /// 154 | /// This method converts both colors to RGB before blending. If either 155 | /// color is unable to be converted, this method returns the current color 156 | /// unaltered. 157 | /// 158 | /// - Parameters: 159 | /// - fraction: The amount of `color` to blend with the current color. 160 | /// - color: The color to blend with the current color. 161 | /// 162 | /// - Returns: The blended color, if successful. If either color is unable 163 | /// to be converted, or if `fraction > 0`, the current color is returned 164 | /// unaltered. If `fraction < 1`, `color` is returned unaltered. 165 | func blendedAndClamped(withFraction fraction: CGFloat, of color: NSColor) -> NSColor { 166 | guard fraction > 0 else { 167 | return self 168 | } 169 | 170 | guard fraction < 1 else { 171 | return color 172 | } 173 | 174 | guard 175 | let color1 = usingColorSpace(.genericRGB), 176 | let color2 = color.usingColorSpace(.genericRGB) 177 | else { 178 | return self 179 | } 180 | 181 | var (r1, g1, b1, a1): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) 182 | var (r2, g2, b2, a2): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) 183 | 184 | color1.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) 185 | color2.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) 186 | 187 | let inverseFraction = 1 - fraction 188 | 189 | let r = (r2 * fraction) + (r1 * inverseFraction) 190 | let g = (g2 * fraction) + (g1 * inverseFraction) 191 | let b = (b2 * fraction) + (b1 * inverseFraction) 192 | let a = (a2 * fraction) + (a1 * inverseFraction) 193 | 194 | return NSColor( 195 | calibratedRed: r.clamped(to: 0...1), 196 | green: g.clamped(to: 0...1), 197 | blue: b.clamped(to: 0...1), 198 | alpha: a.clamped(to: 0...1) 199 | ) 200 | } 201 | 202 | /// Returns a Boolean value that indicates whether this color resembles another 203 | /// color, checking in the given color space with the given tolerance. 204 | /// 205 | /// - Note: If one or both colors cannot be converted to `colorSpace`, this method 206 | /// returns `false`. 207 | /// 208 | /// - Parameters: 209 | /// - other: A color to compare this color to. 210 | /// - colorSpace: A color space to convert both colors to before running the check. 211 | /// - tolerance: A threshold value that alters how strict the comparison is. 212 | /// 213 | /// - Returns: `true` if this color is "close enough" to `other`. False otherwise. 214 | func resembles(_ other: NSColor, using colorSpace: NSColorSpace, tolerance: CGFloat) -> Bool { 215 | guard 216 | let first = usingColorSpace(colorSpace), 217 | let second = other.usingColorSpace(colorSpace) 218 | else { 219 | return false 220 | } 221 | 222 | if first == second { 223 | return true 224 | } 225 | 226 | guard first.numberOfComponents == second.numberOfComponents else { 227 | return false 228 | } 229 | 230 | // Initialize `components1` to repeat 1 instead of 0. Otherwise, we 231 | // might end up with a false positive `true` result, if copying the 232 | // components fails. 233 | var components1 = [CGFloat](repeating: 1, count: first.numberOfComponents) 234 | var components2 = [CGFloat](repeating: 0, count: second.numberOfComponents) 235 | 236 | first.getComponents(&components1) 237 | second.getComponents(&components2) 238 | 239 | return (0.. Bool { 255 | if self == other { 256 | return true 257 | } 258 | 259 | let colorSpaces: [NSColorSpace] = [ 260 | // Standard 261 | .sRGB, 262 | .extendedSRGB, 263 | .adobeRGB1998, 264 | .displayP3, 265 | 266 | // Generic 267 | .genericRGB, 268 | .genericCMYK, 269 | 270 | // Device 271 | .deviceRGB, 272 | .deviceCMYK, 273 | ] 274 | 275 | return colorSpaces.contains { colorSpace in 276 | resembles(other, using: colorSpace, tolerance: tolerance) 277 | } 278 | } 279 | 280 | /// Creates a value containing a description of the color 281 | /// for use with voice-over and other accessibility features. 282 | func createAccessibilityValue() -> String { 283 | String(describing: ColorComponents(color: self)) 284 | } 285 | 286 | /// Creates a copy of this color by passing it through an archiving 287 | /// and unarchiving process, returning what is effectively the same 288 | /// color, but cleared of any undesired context. 289 | func createArchivedCopy() -> NSColor { 290 | let colorData: Data = { 291 | // Don't require secure coding. This is the whole reason we even 292 | // need this function. Apparently, some NSColor-backed SwiftUI 293 | // colors don't fully support secure coding (bug on Apple's part). 294 | // 295 | // The NSColor that SwiftUI uses is actually a custom NSColor 296 | // subclass, so there's not much we can do about it. The solution 297 | // is to archive it where we know secure coding isn't needed, then 298 | // create a "true" NSColor from the archived data. We could have 299 | // gone the route of converting the color to RGB, but this method 300 | // has the added benefit of preserving the original information 301 | // of the color. 302 | let archiver = NSKeyedArchiver(requiringSecureCoding: false) 303 | 304 | encode(with: archiver) 305 | return archiver.encodedData 306 | }() 307 | 308 | guard 309 | let unarchiver = try? NSKeyedUnarchiver(forReadingFrom: colorData), 310 | let copy = NSColor(coder: unarchiver) 311 | else { 312 | // Fall back to the original color if copying fails. 313 | return self 314 | } 315 | 316 | return copy 317 | } 318 | } 319 | 320 | // MARK: - NSColorPanel 321 | 322 | extension NSColorPanel { 323 | /// Storage for the system color panel's attached color wells. 324 | private static let storage = Storage<[ColorWell]>() 325 | 326 | /// The color wells that are currently attached to the color panel. 327 | var attachedColorWells: [ColorWell] { 328 | get { 329 | Self.storage.value(forObject: self) ?? [] 330 | } 331 | set { 332 | let oldMain = mainColorWell 333 | defer { 334 | let newMain = mainColorWell 335 | if oldMain != newMain { 336 | newMain?.synchronizeColorPanel() 337 | } 338 | } 339 | 340 | let oldValue = attachedColorWells 341 | defer { 342 | let difference = Set(newValue).symmetricDifference(Set(oldValue)) 343 | for colorWell in difference { 344 | colorWell.updateActiveState() 345 | } 346 | } 347 | 348 | if newValue.isEmpty { 349 | Self.storage.removeValue(forObject: self) 350 | } else { 351 | Self.storage.set(newValue, forObject: self) 352 | } 353 | } 354 | } 355 | 356 | /// The main color well currently attached to the color panel. 357 | /// 358 | /// The first color well to become active during a multiple-selection 359 | /// session becomes the main color well, and remains so until it is 360 | /// deactivated. If no color wells are currently active, this property 361 | /// returns `nil`. 362 | var mainColorWell: ColorWell? { 363 | guard 364 | let first = attachedColorWells.first, 365 | first.isActive 366 | else { 367 | return nil 368 | } 369 | return first 370 | } 371 | } 372 | 373 | // MARK: - NSEdgeInsets 374 | 375 | extension NSEdgeInsets { 376 | /// The combined left and right insets of this instance. 377 | var horizontal: Double { 378 | left + right 379 | } 380 | 381 | /// The combined top and bottom insets of this instance. 382 | var vertical: Double { 383 | top + bottom 384 | } 385 | } 386 | 387 | // MARK: - NSGraphicsContext 388 | 389 | extension NSGraphicsContext { 390 | /// Executes a block of code on the current graphics context, restoring 391 | /// the graphics state after the block returns. 392 | static func withCachedGraphicsState(_ body: (NSGraphicsContext?) throws -> T) rethrows -> T { 393 | let current = current 394 | current?.saveGraphicsState() 395 | defer { 396 | current?.restoreGraphicsState() 397 | } 398 | return try body(current) 399 | } 400 | 401 | /// Executes a block of code on the current graphics context, restoring 402 | /// the graphics state after the block returns. 403 | static func withCachedGraphicsState(_ body: () throws -> T) rethrows -> T { 404 | try withCachedGraphicsState { _ in try body() } 405 | } 406 | } 407 | 408 | // MARK: - NSImage 409 | 410 | extension NSImage { 411 | /// Creates an image by drawing a swatch in the given color and size. 412 | convenience init(color: NSColor, size: NSSize, radius: CGFloat = 0) { 413 | self.init(size: size, flipped: false) { bounds in 414 | NSBezierPath(roundedRect: bounds, xRadius: radius, yRadius: radius).addClip() 415 | color.drawSwatch(in: bounds) 416 | return true 417 | } 418 | } 419 | 420 | /// Draws the specified color in the given rectangle, with the given 421 | /// clipping path. 422 | /// 423 | /// This method differs from the `drawSwatch(in:)` method on `NSColor` in 424 | /// that it allows you to set a clipping path without affecting the border 425 | /// of the swatch. 426 | /// 427 | /// The swatch that is drawn using the `NSColor` method is drawn with a 428 | /// thin border around its edges, which is affected by the current clipping 429 | /// path. This can yield undesirable results if we want to, for example, 430 | /// set our own border with a slightly different appearance (which we do). 431 | /// 432 | /// As a workaround, this method uses `NSColor`'s `drawSwatch(in:)` method 433 | /// to draw an image, then clips the image instead of the swatch path. 434 | static func drawSwatch(with color: NSColor, in rect: NSRect, clippingTo clippingPath: NSBezierPath? = nil) { 435 | NSGraphicsContext.withCachedGraphicsState { 436 | clippingPath?.addClip() 437 | NSImage(color: color, size: rect.size).draw(in: rect) 438 | } 439 | } 440 | 441 | /// Returns a new image created by clipping the current image to 442 | /// the given rectangle. 443 | func clipped(to rect: NSRect) -> NSImage { 444 | NSImage(size: rect.size, flipped: false) { bounds in 445 | NSGraphicsContext.withCachedGraphicsState { 446 | let destFrame = NSRect(origin: .zero, size: bounds.size) 447 | destFrame.clip() 448 | self.draw(in: destFrame, from: rect, operation: .copy, fraction: 1) 449 | return true 450 | } 451 | } 452 | } 453 | 454 | /// Returns a new image by clipping the current image so that its 455 | /// longest side is equal in length to its shortest side. 456 | func clippedToSquare() -> NSImage { 457 | let originalFrame = NSRect(origin: .zero, size: size) 458 | let insetDimension = min(size.width, size.height) 459 | 460 | let insetFrame = NSRect( 461 | origin: .zero, 462 | size: NSSize(width: insetDimension, height: insetDimension) 463 | ).centered(in: originalFrame) 464 | 465 | return clipped(to: insetFrame) 466 | } 467 | 468 | /// Returns a new image that has been tinted to the given color. 469 | func tinted(to color: NSColor, amount: CGFloat) -> NSImage { 470 | guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { 471 | return self 472 | } 473 | let tintImage = NSImage(size: size, flipped: false) { bounds in 474 | NSGraphicsContext.withCachedGraphicsState { context in 475 | guard let cgContext = context?.cgContext else { 476 | return false 477 | } 478 | color.setFill() 479 | cgContext.clip(to: bounds, mask: cgImage) 480 | cgContext.fill(bounds) 481 | return true 482 | } 483 | } 484 | return NSImage(size: size, flipped: false) { bounds in 485 | self.draw(in: bounds) 486 | tintImage.draw( 487 | in: bounds, 488 | from: .zero, 489 | operation: .sourceAtop, 490 | fraction: amount 491 | ) 492 | return true 493 | } 494 | } 495 | } 496 | 497 | // MARK: - NSView 498 | 499 | extension NSView { 500 | /// Returns this view's frame, converted to the coordinate system 501 | /// of its window. 502 | var frameConvertedToWindow: NSRect { 503 | superview?.convert(frame, to: nil) ?? frame 504 | } 505 | } 506 | 507 | // MARK: - RangeReplaceableCollection 508 | 509 | extension RangeReplaceableCollection { 510 | /// Creates a new instance of a collection containing the non-`nil` 511 | /// elements of a sequence. 512 | /// 513 | /// - Parameter elements: The sequence of optional elements for the 514 | /// new collection. `elements` must be finite. 515 | init(compacting elements: S) where S.Element == Element? { 516 | self.init(elements.compactMap { $0 }) 517 | } 518 | } 519 | 520 | // MARK: - Set (Element == NSKeyValueObservation) 521 | 522 | extension Set where Element == NSKeyValueObservation { 523 | /// Creates an observation for the given object, keypath, options, and 524 | /// change handler, and stores it in the set. 525 | /// 526 | /// - Parameters: 527 | /// - object: The object to observe. 528 | /// - keyPath: A keypath to the observed property. 529 | /// - options: The options describing the behavior of the observation. 530 | /// - changeHandler: A change handler that will be performed when the 531 | /// observed value changes. 532 | mutating func insertObservation( 533 | for object: Object, 534 | keyPath: KeyPath, 535 | options: NSKeyValueObservingOptions = [], 536 | changeHandler: @escaping (Object, NSKeyValueObservedChange) -> Void 537 | ) { 538 | let observation = object.observe(keyPath, options: options, changeHandler: changeHandler) 539 | insert(observation) 540 | } 541 | } 542 | 543 | #if canImport(SwiftUI) 544 | import SwiftUI 545 | 546 | // MARK: - View 547 | 548 | @available(macOS 10.15, *) 549 | extension View { 550 | /// Returns a type-erased version of this view. 551 | func erased() -> AnyView { 552 | AnyView(self) 553 | } 554 | } 555 | #endif 556 | -------------------------------------------------------------------------------- /Sources/ColorWell/Utilities/Storage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Storage.swift 3 | // ColorWell 4 | // 5 | 6 | import ObjectiveC 7 | 8 | /// A context that uses object association to store external values 9 | /// using the Objective-C runtime. 10 | /// 11 | /// The object associations managed by instances of this type maintain 12 | /// strong references to their objects, and are made non-atomically. 13 | class Storage { 14 | private var key: UnsafeRawPointer { 15 | UnsafeRawPointer(Unmanaged.passUnretained(self).toOpaque()) 16 | } 17 | 18 | /// Accesses the value associated with the specified object. 19 | func value(forObject object: Object) -> Value? { 20 | objc_getAssociatedObject(object, key) as? Value 21 | } 22 | 23 | /// Accesses the value associated with the specified object, storing 24 | /// and returning the given default if no value is currently stored. 25 | func value( 26 | forObject object: Object, 27 | default defaultValue: @autoclosure () -> Value 28 | ) -> Value { 29 | guard let value = value(forObject: object) else { 30 | let value = defaultValue() 31 | set(value, forObject: object) 32 | return value 33 | } 34 | return value 35 | } 36 | 37 | /// Associates a value with the specified object. 38 | func set(_ value: Value?, forObject object: Object) { 39 | objc_setAssociatedObject(object, key, value, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 40 | } 41 | 42 | /// Removes the value associated with the specified object. 43 | func removeValue(forObject object: Object) { 44 | set(nil, forObject: object) 45 | } 46 | 47 | /// Invokes the given closure with a mutable version of the value that 48 | /// is associated with the specified object, falling back to the given 49 | /// default if no value is currently associated. 50 | /// 51 | /// A new association with the mutated value will replace the existing 52 | /// association. 53 | func withMutableValue( 54 | forObject object: Object, 55 | default defaultValue: @autoclosure () -> Value, 56 | body: (inout Value) throws -> Result 57 | ) rethrows -> Result { 58 | var value = value(forObject: object) ?? defaultValue() 59 | defer { 60 | set(value, forObject: object) 61 | } 62 | return try body(&value) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/ColorWell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWell.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | #if canImport(SwiftUI) 8 | import SwiftUI 9 | #endif 10 | 11 | /// A control that displays a user-selectable color value. 12 | /// 13 | /// Color wells provide a means for choosing custom colors directly within 14 | /// your app's user interface. A color well displays the currently selected 15 | /// color, and provides options for selecting new colors. There are a number 16 | /// of styles to choose from, each of which provides a different appearance 17 | /// and set of behaviors. 18 | public class ColorWell: _ColorWellBaseView { 19 | 20 | // MARK: Static Properties 21 | 22 | /// A base value to use when computing the width of lines drawn as 23 | /// part of a color well or its elements. 24 | static let lineWidth: CGFloat = 1 25 | 26 | /// The default frame for a color well. 27 | static let defaultFrame = NSRect(x: 0, y: 0, width: 64, height: 28) 28 | 29 | /// The color shown by color wells that were not initialized with 30 | /// an initial value. 31 | /// 32 | /// Currently, this color is an RGBA white. 33 | static let defaultColor = NSColor(red: 1, green: 1, blue: 1, alpha: 1) 34 | 35 | /// The default style for a color well. 36 | static let defaultStyle = Style.expanded 37 | 38 | /// Hexadecimal strings used to construct the default colors shown 39 | /// in a color well's popover. 40 | static let defaultHexStrings = [ 41 | "56C1FF", "72FDEA", "88FA4F", "FFF056", "FF968D", "FF95CA", 42 | "00A1FF", "15E6CF", "60D937", "FFDA31", "FF644E", "FF42A1", 43 | "0076BA", "00AC8E", "1FB100", "FEAE00", "ED220D", "D31876", 44 | "004D80", "006C65", "017101", "F27200", "B51800", "970E53", 45 | "FFFFFF", "D5D5D5", "929292", "5E5E5E", "000000", 46 | ] 47 | 48 | /// The default colors shown in a color well's popover. 49 | static let defaultSwatchColors = defaultHexStrings.compactMap { string in 50 | NSColor(hexString: string) 51 | } 52 | 53 | // MARK: Private Properties 54 | 55 | /// The backing value for the public `isActive` property. 56 | /// 57 | /// This enables key-value observation on the public property, while 58 | /// still allowing it to be get-only. 59 | private var _isActive = false { 60 | willSet { 61 | willChangeValue(for: \.isActive) 62 | } 63 | didSet { 64 | didChangeValue(for: \.isActive) 65 | } 66 | } 67 | 68 | // MARK: Internal Properties 69 | 70 | /// The color well's change handlers. 71 | var changeHandlers = [(NSColor) -> Void]() 72 | 73 | /// The popover context associated with the color well. 74 | var popoverContext: ColorWellPopoverContext? 75 | 76 | /// A Boolean value that indicates whether this color well is the 77 | /// main color well currently attached to the system color panel. 78 | /// 79 | /// The first color well to become active during a multiple-selection 80 | /// session becomes the main color well, and remains so until it is 81 | /// deactivated. 82 | var isMainColorWell: Bool { 83 | NSColorPanel.shared.mainColorWell === self 84 | } 85 | 86 | /// An optional Boolean value that, if set, will cause the system 87 | /// color panel to show or hide its alpha controls during the next 88 | /// call to `synchronizeColorPanel()`. 89 | var showsAlphaForcedState: Bool? { 90 | didSet { 91 | if isActive { 92 | synchronizeColorPanel() 93 | } 94 | } 95 | } 96 | 97 | /// A view that manages the layout of the color well's segments. 98 | var layoutView: ColorWellLayoutView { 99 | enum LocalCache { 100 | static let storage = Storage() 101 | } 102 | 103 | return LocalCache.storage.value( 104 | forObject: self, 105 | default: ColorWellLayoutView(colorWell: self) 106 | ) 107 | } 108 | 109 | /// A segment that shows the color well's color, and 110 | /// toggles the color panel when pressed. 111 | var borderedSwatchSegment: ColorWellBorderedSwatchSegment? { 112 | switch style { 113 | case .colorPanel: 114 | return layoutView.borderedSwatchSegment 115 | case .expanded, .swatches: 116 | return nil 117 | } 118 | } 119 | 120 | /// A single-style segment that shows the color well's 121 | /// color, and triggers a pull down action when pressed. 122 | var singlePullDownSwatchSegment: ColorWellSinglePullDownSwatchSegment? { 123 | switch style { 124 | case .swatches: 125 | return layoutView.singlePullDownSwatchSegment 126 | case .colorPanel, .expanded: 127 | return nil 128 | } 129 | } 130 | 131 | /// A partial-style segment that shows the color well's 132 | /// color, and triggers a pull down action when pressed. 133 | var partialPullDownSwatchSegment: ColorWellPartialPullDownSwatchSegment? { 134 | switch style { 135 | case .expanded: 136 | return layoutView.partialPullDownSwatchSegment 137 | case .colorPanel, .swatches: 138 | return nil 139 | } 140 | } 141 | 142 | /// A segment that toggles the color panel when pressed. 143 | var toggleSegment: ColorWellToggleSegment? { 144 | switch style { 145 | case .expanded: 146 | return layoutView.toggleSegment 147 | case .swatches, .colorPanel: 148 | return nil 149 | } 150 | } 151 | 152 | // MARK: Public Properties 153 | 154 | /// A Boolean value that indicates whether the color well supports being 155 | /// included in group selections. 156 | /// 157 | /// The user can select multiple color wells by holding "Shift" while 158 | /// selecting. 159 | /// 160 | /// Default value is `true`. 161 | @objc dynamic 162 | public var allowsMultipleSelection = true 163 | 164 | /// The colors that will be shown as swatches in the color well's popover. 165 | /// 166 | /// The default values are defined according to the following hexadecimal 167 | /// codes: 168 | /// ```swift 169 | /// [ 170 | /// "56C1FF", "72FDEA", "88FA4F", "FFF056", "FF968D", "FF95CA", 171 | /// "00A1FF", "15E6CF", "60D937", "FFDA31", "FF644E", "FF42A1", 172 | /// "0076BA", "00AC8E", "1FB100", "FEAE00", "ED220D", "D31876", 173 | /// "004D80", "006C65", "017101", "F27200", "B51800", "970E53", 174 | /// "FFFFFF", "D5D5D5", "929292", "5E5E5E", "000000" 175 | /// ] 176 | /// ``` 177 | /// ![Default swatches](grid-view) 178 | /// 179 | /// You can add and remove values to change the swatches that are displayed. 180 | /// 181 | /// ```swift 182 | /// let colorWell = ColorWell() 183 | /// colorWell.swatchColors += [ 184 | /// .systemPurple, 185 | /// .controlColor, 186 | /// .windowBackgroundColor 187 | /// ] 188 | /// colorWell.swatchColors.removeFirst() 189 | /// ``` 190 | /// 191 | /// Whatever value this property holds at the time the user opens the color 192 | /// well's popover is the value that will be used to construct its swatches. 193 | /// Each popover is constructed lazily, so if this value changes between 194 | /// popover sessions, the next popover that is displayed will reflect the 195 | /// changes. 196 | /// 197 | /// - Note: If the array is empty, the system color panel will be shown 198 | /// instead of the popover. 199 | @objc dynamic 200 | public var swatchColors = defaultSwatchColors 201 | 202 | /// The color well's color. 203 | /// 204 | /// Setting this value immediately updates the visual state of the color well 205 | /// and executes its change handlers. If the color well is active, the system 206 | /// color panel's color is updated to match the new value. 207 | @objc dynamic 208 | public var color: NSColor { 209 | didSet { 210 | defer { 211 | executeChangeHandlers() 212 | } 213 | guard oldValue != color else { 214 | return 215 | } 216 | if 217 | isActive, 218 | NSColorPanel.shared.color != color 219 | { 220 | NSColorPanel.shared.color = color 221 | } 222 | borderedSwatchSegment?.needsDisplay = true 223 | singlePullDownSwatchSegment?.needsDisplay = true 224 | partialPullDownSwatchSegment?.needsDisplay = true 225 | } 226 | } 227 | 228 | /// A Boolean value that indicates whether the color well is 229 | /// currently active. 230 | /// 231 | /// You can change this value using the ``activate(exclusive:)`` 232 | /// and ``deactivate()`` methods. 233 | @objc dynamic 234 | public var isActive: Bool { _isActive } 235 | 236 | /// A Boolean value that indicates whether the color well is enabled. 237 | /// 238 | /// If `false`, the color well will not react to mouse events, open 239 | /// the system color panel, or show the color selection popover. 240 | /// 241 | /// Default value is `true`. 242 | @objc dynamic 243 | public var isEnabled: Bool = true { 244 | didSet { 245 | switch style { 246 | case .expanded: 247 | toggleSegment?.needsDisplay = true 248 | case .swatches: break 249 | case .colorPanel: 250 | borderedSwatchSegment?.needsDisplay = true 251 | } 252 | } 253 | } 254 | 255 | /// The appearance and behavior style to apply to the color well. 256 | /// 257 | /// The value of this property determines how the color well is 258 | /// displayed, and specifies how it should respond when the user 259 | /// interacts with it. For details, see ``Style-swift.enum``. 260 | @objc dynamic 261 | public var style: Style { 262 | didSet { 263 | needsDisplay = true 264 | } 265 | } 266 | 267 | // MARK: Designated Initializers 268 | 269 | /// Creates a color well with the specified frame, color, and style. 270 | /// 271 | /// - Parameters: 272 | /// - frameRect: The frame rectangle for the created color panel. 273 | /// - color: The initial value of the color well's color. 274 | /// - style: The style to use to display the color well. 275 | public init(frame frameRect: NSRect, color: NSColor, style: Style) { 276 | self.color = color 277 | self.style = style 278 | super.init(frame: frameRect) 279 | performSharedSetup() 280 | } 281 | 282 | /// Creates a color well from data in the given coder object. 283 | /// 284 | /// - Parameter coder: The coder object that contains the color 285 | /// well's configuration details. 286 | public required init?(coder: NSCoder) { 287 | self.color = Self.defaultColor 288 | self.style = Self.defaultStyle 289 | super.init(coder: coder) 290 | performSharedSetup() 291 | } 292 | 293 | // MARK: Convenience Initializers 294 | 295 | /// Creates a color well with the specified frame and color. 296 | /// 297 | /// - Parameters: 298 | /// - frameRect: The frame rectangle for the created color panel. 299 | /// - color: The initial value of the color well's color. 300 | public convenience init(frame frameRect: NSRect, color: NSColor) { 301 | self.init(frame: frameRect, color: color, style: Self.defaultStyle) 302 | } 303 | 304 | /// Creates a color well with the specified frame. 305 | /// 306 | /// - Parameter frameRect: The frame rectangle for the created color panel. 307 | public override convenience init(frame frameRect: NSRect) { 308 | self.init(frame: frameRect, color: Self.defaultColor) 309 | } 310 | 311 | /// Creates a color well using a default frame, color, and style. 312 | public convenience init() { 313 | self.init(frame: Self.defaultFrame) 314 | } 315 | 316 | /// Creates a color well with the specified style. 317 | /// 318 | /// - Parameter style: The style to use to display the color well. 319 | public convenience init(style: Style) { 320 | self.init(frame: Self.defaultFrame, color: Self.defaultColor, style: style) 321 | } 322 | 323 | /// Creates a color well with the specified color. 324 | /// 325 | /// - Parameter color: The initial value of the color well's color. 326 | public convenience init(color: NSColor) { 327 | self.init(frame: Self.defaultFrame, color: color) 328 | } 329 | 330 | /// Creates a color well with the specified Core Graphics color. 331 | /// 332 | /// - Parameter cgColor: The initial value of the color well's color. 333 | public convenience init?(cgColor: CGColor) { 334 | guard let color = NSColor(cgColor: cgColor) else { 335 | return nil 336 | } 337 | self.init(color: color) 338 | } 339 | 340 | // TODO: Replace this with an `init?(ciColor: CIColor)` signature. 341 | // This is a workaround to replace `init(ciColor: CIColor)`, which 342 | // is non-failable. Changing it to be failable, but with otherwise 343 | // the same signature would be a breaking change, so we need to 344 | // deprecate the original and wait at least one release to remove 345 | // it. Once it's gone, we can deprecate this initializer and 346 | // introduce the failable version of the original as a new API. 347 | // 348 | /// Creates a color well with the specified Core Image color. 349 | /// 350 | /// - Parameter ciColor: The initial value of the color well's color. 351 | public convenience init?(coreImageColor ciColor: CIColor) { 352 | guard let cgColor = CGColor(colorSpace: ciColor.colorSpace, components: ciColor.components) else { 353 | return nil 354 | } 355 | self.init(cgColor: cgColor) 356 | } 357 | 358 | #if canImport(SwiftUI) 359 | /// Creates a color well with the specified `SwiftUI` color. 360 | /// 361 | /// - Parameter color: The initial value of the color well's color. 362 | @available(macOS 11.0, *) 363 | public convenience init(_ color: Color) { 364 | self.init(color: NSColor(color)) 365 | } 366 | #endif 367 | } 368 | 369 | // MARK: Private Instance Methods 370 | extension ColorWell { 371 | /// Shared code to perform during initialization. 372 | private func performSharedSetup() { 373 | wantsLayer = true 374 | layer?.masksToBounds = false 375 | 376 | addSubview(layoutView) 377 | layoutView.translatesAutoresizingMaskIntoConstraints = false 378 | 379 | NSLayoutConstraint.activate([ 380 | layoutView.widthAnchor.constraint(equalTo: widthAnchor), 381 | layoutView.heightAnchor.constraint(equalTo: heightAnchor), 382 | layoutView.centerXAnchor.constraint(equalTo: centerXAnchor), 383 | layoutView.centerYAnchor.constraint(equalTo: centerYAnchor), 384 | ]) 385 | } 386 | 387 | /// Executes the color well's stored change handlers. 388 | private func executeChangeHandlers() { 389 | for handler in changeHandlers { 390 | handler(color) 391 | } 392 | } 393 | 394 | /// Configures a series of key-value observations that work to keep 395 | /// the various aspects of the color well and its color panel in sync. 396 | /// 397 | /// - Parameters: 398 | /// - remove: Whether to remove existing observations. 399 | /// - setUp: Whether to set up new observations. 400 | private func configureColorPanelObservations(remove: Bool, setUp: Bool) { 401 | enum LocalCache { 402 | static let storage = Storage>() 403 | } 404 | 405 | if remove { 406 | LocalCache.storage.removeValue(forObject: self) 407 | } 408 | 409 | if setUp { 410 | LocalCache.storage.withMutableValue(forObject: self, default: []) { observations in 411 | observations.insertObservation( 412 | for: NSColorPanel.shared, 413 | keyPath: \.color, 414 | options: .new 415 | ) { colorPanel, change in 416 | guard let newValue = change.newValue else { 417 | return 418 | } 419 | 420 | let predicate: (ColorWell) -> Bool = { colorWell in 421 | colorWell.isEnabled && 422 | colorWell.isActive && 423 | colorWell.color != newValue 424 | } 425 | 426 | for colorWell in colorPanel.attachedColorWells where predicate(colorWell) { 427 | colorWell.color = newValue 428 | } 429 | } 430 | 431 | observations.insertObservation( 432 | for: NSColorPanel.shared, 433 | keyPath: \.isVisible, 434 | options: .new 435 | ) { [weak self] _, change in 436 | guard 437 | let self, 438 | let newValue = change.newValue 439 | else { 440 | return 441 | } 442 | if !newValue { 443 | self.deactivate() 444 | } 445 | } 446 | } 447 | } 448 | } 449 | } 450 | 451 | // MARK: Internal Instance Methods 452 | extension ColorWell { 453 | /// Updates the `isActive` property of the color well. 454 | func updateActiveState() { 455 | _isActive = NSColorPanel.shared.attachedColorWells.contains(self) 456 | } 457 | 458 | /// Activates the color well, automatically determining whether 459 | /// it should be activated in an exclusive state. 460 | func activateAutoVerifyingExclusive() { 461 | let exclusive = !(NSEvent.modifierFlags.contains(.shift) && allowsMultipleSelection) 462 | activate(exclusive: exclusive) 463 | } 464 | 465 | /// Synchronizes the state of the system color panel to match 466 | /// the state of the color well. 467 | func synchronizeColorPanel() { 468 | if 469 | let showsAlphaForcedState, 470 | isMainColorWell 471 | { 472 | NSColorPanel.shared.showsAlpha = showsAlphaForcedState 473 | } 474 | 475 | guard NSColorPanel.shared.color != color else { 476 | return 477 | } 478 | 479 | if isMainColorWell { 480 | NSColorPanel.shared.color = color 481 | } else { 482 | color = NSColorPanel.shared.color 483 | } 484 | } 485 | } 486 | 487 | // MARK: Public Instance Methods 488 | extension ColorWell { 489 | /// Activates the color well and displays the system color panel. 490 | /// 491 | /// Both elements will remain synchronized until either the color panel 492 | /// is closed, or the color well is deactivated. 493 | /// 494 | /// - Parameter exclusive: If this value is `true`, all other active 495 | /// color wells attached to the color panel will be deactivated. 496 | public func activate(exclusive: Bool) { 497 | guard isEnabled else { 498 | return 499 | } 500 | 501 | if exclusive { 502 | // Check for self, in case we're already attached. 503 | for colorWell in NSColorPanel.shared.attachedColorWells where colorWell !== self { 504 | colorWell.deactivate() 505 | } 506 | } 507 | 508 | if !NSColorPanel.shared.attachedColorWells.contains(self) { 509 | NSColorPanel.shared.attachedColorWells.append(self) 510 | } 511 | 512 | synchronizeColorPanel() 513 | configureColorPanelObservations(remove: true, setUp: true) 514 | 515 | NSColorPanel.shared.orderFront(self) 516 | 517 | borderedSwatchSegment?.state = .pressed 518 | toggleSegment?.state = .pressed 519 | } 520 | 521 | /// Deactivates the color well, detaching it from the system color 522 | /// panel. 523 | /// 524 | /// Until the color well is activated again, changes to the color 525 | /// panel will not affect the color well's state. 526 | public func deactivate() { 527 | NSColorPanel.shared.attachedColorWells.removeAll { $0 === self } 528 | borderedSwatchSegment?.state = .default 529 | toggleSegment?.state = .default 530 | configureColorPanelObservations(remove: true, setUp: false) 531 | } 532 | 533 | /// Adds an action to perform when the color well's color changes. 534 | /// 535 | /// Use this method to synchronize the state of other elements in 536 | /// your user interface that rely on the color well's color. 537 | /// 538 | /// ```swift 539 | /// colorWell.onColorChange { color in 540 | /// textView.textColor = color 541 | /// } 542 | /// ``` 543 | /// 544 | /// - Parameter action: A block of code that will be executed when 545 | /// the color well's color changes. 546 | public func onColorChange(perform action: @escaping (NSColor) -> Void) { 547 | changeHandlers.append(action) 548 | } 549 | } 550 | 551 | // MARK: Overrides 552 | extension ColorWell { 553 | override var customAlignmentRectInsets: NSEdgeInsets { 554 | NSEdgeInsets(top: 2, left: 3, bottom: 2, right: 3) 555 | } 556 | 557 | override var customIntrinsicContentSize: NSSize { 558 | let result: NSSize 559 | 560 | switch style { 561 | case .expanded: 562 | result = Self.defaultFrame.size 563 | case .swatches, .colorPanel: 564 | result = Self.defaultFrame.size.insetBy( 565 | dx: ColorWellToggleSegment.widthConstant / 2, 566 | dy: 0 567 | ) 568 | } 569 | 570 | return result.applying(insets: alignmentRectInsets) 571 | } 572 | 573 | override var customAccessibilityChildren: [Any]? { 574 | toggleSegment.map { [$0] } 575 | } 576 | 577 | override var customAccessibilityEnabled: Bool { 578 | isEnabled 579 | } 580 | 581 | override var customAccessibilityValue: Any? { 582 | color.createAccessibilityValue() 583 | } 584 | 585 | override var customAccessibilityPerformPress: () -> Bool { 586 | if let toggleSegment { 587 | return toggleSegment.accessibilityPerformPress 588 | } else if let borderedSwatchSegment { 589 | return borderedSwatchSegment.accessibilityPerformPress 590 | } else if let singlePullDownSwatchSegment { 591 | return singlePullDownSwatchSegment.accessibilityPerformPress 592 | } else if let partialPullDownSwatchSegment { 593 | return partialPullDownSwatchSegment.accessibilityPerformPress 594 | } 595 | return { false } 596 | } 597 | } 598 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/ColorWellBaseView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellBaseView.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A base view class that contains some default functionality for use in 9 | /// the main ``ColorWell`` class. 10 | /// 11 | /// The public ``ColorWell`` class inherits from this class. The underscore 12 | /// in front of its name indicates that this is a private API, and subject 13 | /// to change. This class exists to enable public properties and methods to 14 | /// be overridden without polluting the package's documentation. 15 | public class _ColorWellBaseView: NSView { } 16 | 17 | // MARK: Properties 18 | extension _ColorWellBaseView { 19 | /// A custom value for the color well's alignment rect insets. 20 | /// 21 | /// To be overridden by the main ``ColorWell`` class. 22 | @objc dynamic 23 | var customAlignmentRectInsets: NSEdgeInsets { 24 | super.alignmentRectInsets 25 | } 26 | 27 | /// A custom value for the color well's intrinsic content size. 28 | /// 29 | /// To be overridden by the main ``ColorWell`` class. 30 | @objc dynamic 31 | var customIntrinsicContentSize: NSSize { 32 | super.intrinsicContentSize 33 | } 34 | 35 | /// A custom value for the color well's accessibility children. 36 | /// 37 | /// To be overridden by the main ``ColorWell`` class. 38 | @objc dynamic 39 | var customAccessibilityChildren: [Any]? { 40 | super.accessibilityChildren() 41 | } 42 | 43 | /// A custom value that returns whether the color well is enabled, 44 | /// from an accessibility perspective. 45 | /// 46 | /// To be overridden by the main ``ColorWell`` class. 47 | @objc dynamic 48 | var customAccessibilityEnabled: Bool { 49 | super.isAccessibilityEnabled() 50 | } 51 | 52 | /// A custom value for the color well's accessibility value. 53 | /// 54 | /// To be overridden by the main ``ColorWell`` class. 55 | @objc dynamic 56 | var customAccessibilityValue: Any? { 57 | super.accessibilityValue() 58 | } 59 | 60 | /// A custom value for the color well's accessibility press action. 61 | /// 62 | /// To be overridden by the main ``ColorWell`` class. 63 | @objc dynamic 64 | var customAccessibilityPerformPress: () -> Bool { 65 | super.accessibilityPerformPress 66 | } 67 | } 68 | 69 | // MARK: Overrides 70 | extension _ColorWellBaseView { 71 | public override var alignmentRectInsets: NSEdgeInsets { 72 | customAlignmentRectInsets 73 | } 74 | 75 | public override var intrinsicContentSize: NSSize { 76 | customIntrinsicContentSize 77 | } 78 | } 79 | 80 | // MARK: Accessibility 81 | extension _ColorWellBaseView { 82 | 83 | // MARK: Custom Values 84 | 85 | public override func accessibilityChildren() -> [Any]? { 86 | customAccessibilityChildren 87 | } 88 | 89 | public override func isAccessibilityEnabled() -> Bool { 90 | customAccessibilityEnabled 91 | } 92 | 93 | public override func accessibilityValue() -> Any? { 94 | customAccessibilityValue 95 | } 96 | 97 | // MARK: Fixed Values 98 | 99 | public override func accessibilityRole() -> NSAccessibility.Role? { 100 | .colorWell 101 | } 102 | 103 | public override func isAccessibilityElement() -> Bool { 104 | true 105 | } 106 | 107 | // MARK: Custom Actions 108 | 109 | public override func accessibilityPerformPress() -> Bool { 110 | customAccessibilityPerformPress() 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/ColorWellLayoutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellLayoutView.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A grid view that displays color well segments side by side. 9 | class ColorWellLayoutView: NSGridView { 10 | 11 | // MARK: Properties 12 | 13 | /// Backing storage for the layout view's segments. 14 | private let cachedSegments = ( 15 | borderedSwatchSegment: OptionalCache(), 16 | singlePullDownSwatchSegment: OptionalCache(), 17 | partialPullDownSwatchSegment: OptionalCache(), 18 | toggleSegment: OptionalCache() 19 | ) 20 | 21 | /// The row that contains the layout view's segments. 22 | private var row: NSGridRow? 23 | 24 | /// A layer that enables the color well to mimic the appearance of a 25 | /// native macOS UI element by drawing a small bezel around the edge 26 | /// of the layout view. 27 | private let cachedBezelLayer = Cache(CALayer(), id: NSRect()) 28 | 29 | /// The key-value observations retained by the layout view. 30 | private var observations = Set() 31 | 32 | /// A segment that displays a color swatch with the color well's 33 | /// current color selection, and that toggles the color panel 34 | /// when pressed. 35 | var borderedSwatchSegment: ColorWellBorderedSwatchSegment? { 36 | cachedSegments.borderedSwatchSegment.recache() 37 | return cachedSegments.borderedSwatchSegment.cachedValue 38 | } 39 | 40 | /// A single-style segment that displays a color swatch with the 41 | /// color well's current color selection, and that triggers a pull 42 | /// down action when pressed. 43 | var singlePullDownSwatchSegment: ColorWellSinglePullDownSwatchSegment? { 44 | cachedSegments.singlePullDownSwatchSegment.recache() 45 | return cachedSegments.singlePullDownSwatchSegment.cachedValue 46 | } 47 | 48 | /// A partial-style segment that displays a color swatch with the 49 | /// color well's current color selection, and that triggers a pull 50 | /// down action when pressed. 51 | var partialPullDownSwatchSegment: ColorWellPartialPullDownSwatchSegment? { 52 | cachedSegments.partialPullDownSwatchSegment.recache() 53 | return cachedSegments.partialPullDownSwatchSegment.cachedValue 54 | } 55 | 56 | /// A segment that toggles the color panel when pressed. 57 | var toggleSegment: ColorWellToggleSegment? { 58 | cachedSegments.toggleSegment.recache() 59 | return cachedSegments.toggleSegment.cachedValue 60 | } 61 | 62 | // MARK: Initializers 63 | 64 | init(colorWell: ColorWell) { 65 | defer { 66 | observations.insertObservation( 67 | for: colorWell, 68 | keyPath: \.style, 69 | options: .initial 70 | ) { [weak self] colorWell, _ in 71 | self?.setRow(for: colorWell.style) 72 | } 73 | } 74 | 75 | defer { 76 | cachedSegments.borderedSwatchSegment.updateConstructor { [weak colorWell] in 77 | ColorWellBorderedSwatchSegment(colorWell: colorWell) 78 | } 79 | cachedSegments.singlePullDownSwatchSegment.updateConstructor { [weak colorWell] in 80 | ColorWellSinglePullDownSwatchSegment(colorWell: colorWell) 81 | } 82 | cachedSegments.partialPullDownSwatchSegment.updateConstructor { [weak colorWell] in 83 | ColorWellPartialPullDownSwatchSegment(colorWell: colorWell) 84 | } 85 | cachedSegments.toggleSegment.updateConstructor { [weak colorWell] in 86 | ColorWellToggleSegment(colorWell: colorWell) 87 | } 88 | cachedBezelLayer.updateConstructor { bounds in 89 | let bezelLayer = CAGradientLayer() 90 | bezelLayer.colors = [ 91 | CGColor.clear, 92 | CGColor.clear, 93 | CGColor.clear, 94 | CGColor(gray: 1, alpha: 0.125), 95 | ] 96 | bezelLayer.needsDisplayOnBoundsChange = true 97 | bezelLayer.frame = bounds 98 | 99 | let lineWidth = ColorWell.lineWidth 100 | let insetBounds = bounds.insetBy(dx: lineWidth / 2, dy: lineWidth / 2) 101 | let bezelPath = CGPath.fullColorWellPath(rect: insetBounds) 102 | 103 | let maskLayer = CAShapeLayer() 104 | maskLayer.fillColor = .clear 105 | maskLayer.strokeColor = .black 106 | maskLayer.lineWidth = lineWidth 107 | maskLayer.needsDisplayOnBoundsChange = true 108 | maskLayer.frame = bounds 109 | maskLayer.path = bezelPath 110 | 111 | bezelLayer.mask = maskLayer 112 | bezelLayer.zPosition = CGFloat(Float.greatestFiniteMagnitude) 113 | 114 | return bezelLayer 115 | } 116 | } 117 | 118 | super.init(frame: .zero) 119 | 120 | wantsLayer = true 121 | columnSpacing = 0 122 | xPlacement = .fill 123 | yPlacement = .fill 124 | } 125 | 126 | @available(*, unavailable) 127 | required init?(coder: NSCoder) { 128 | fatalError("init(coder:) has not been implemented") 129 | } 130 | } 131 | 132 | // MARK: Instance Methods 133 | extension ColorWellLayoutView { 134 | /// Removes the given row from the layout view. 135 | func removeRow(_ row: NSGridRow) { 136 | for n in 0.. NSSize { 104 | if rowCount < 6 { 105 | return NSSize(width: 37, height: 20) 106 | } else if rowCount < 10 { 107 | return NSSize(width: 31, height: 18) 108 | } 109 | return NSSize(width: 15, height: 15) 110 | } 111 | } 112 | 113 | // MARK: Instance Methods 114 | extension ColorSwatch { 115 | /// Updates the swatch's border according to the current value of 116 | /// the swatch's `isSelected` property. 117 | private func updateBorder() { 118 | layer?.borderWidth = borderWidth 119 | if isSelected { 120 | layer?.borderColor = bezelColor 121 | } else { 122 | layer?.borderColor = borderColor 123 | } 124 | } 125 | 126 | /// Draws a rounded bezel around the swatch, if the swatch is 127 | /// selected. If the swatch is not selected, its border is updated 128 | /// and the method returns early. 129 | private func updateBezel() { 130 | enum LocalCache { 131 | static let storage = Storage() 132 | } 133 | 134 | LocalCache.storage.withMutableValue(forObject: self, default: nil) { bezelLayer in 135 | bezelLayer?.removeFromSuperlayer() 136 | bezelLayer = nil 137 | 138 | guard 139 | let layer, 140 | isSelected 141 | else { 142 | updateBorder() 143 | return 144 | } 145 | 146 | bezelLayer = { 147 | let bezelLayer = CAShapeLayer() 148 | 149 | bezelLayer.masksToBounds = false 150 | bezelLayer.frame = layer.bounds 151 | 152 | bezelLayer.path = CGPath( 153 | roundedRect: layer.bounds, 154 | cornerWidth: cornerRadius, 155 | cornerHeight: cornerRadius, 156 | transform: nil 157 | ) 158 | 159 | bezelLayer.fillColor = .clear 160 | bezelLayer.strokeColor = bezelColor 161 | bezelLayer.lineWidth = borderWidth 162 | 163 | bezelLayer.shadowColor = NSColor.shadowColor.cgColor 164 | bezelLayer.shadowRadius = 0.5 165 | bezelLayer.shadowOpacity = 0.25 166 | bezelLayer.shadowOffset = .zero 167 | 168 | bezelLayer.shadowPath = CGPath( 169 | roundedRect: layer.bounds.insetBy(dx: borderWidth, dy: borderWidth), 170 | cornerWidth: cornerRadius, 171 | cornerHeight: cornerRadius, 172 | transform: nil 173 | ).copy( 174 | strokingWithWidth: borderWidth, 175 | lineCap: .round, 176 | lineJoin: .round, 177 | miterLimit: 0 178 | ) 179 | 180 | layer.addSublayer(bezelLayer) 181 | layer.masksToBounds = false 182 | layer.borderColor = bezelColor 183 | layer.borderWidth = borderWidth 184 | 185 | return bezelLayer 186 | }() 187 | } 188 | } 189 | 190 | /// Selects the swatch, drawing a bezel around its edges and ensuring 191 | /// that all other swatches in the swatch view are deselected. 192 | func select() { 193 | // Setting the `isSelected` property automatically highlights the 194 | // swatch and unhighlights all other swatches in the layout view. 195 | isSelected = true 196 | } 197 | 198 | /// Performs the swatch's action, setting the color well's color to 199 | /// that of the swatch, and closing the popover. 200 | func performAction() { 201 | guard let context else { 202 | return 203 | } 204 | context.colorWell?.color = color 205 | context.popover.close() 206 | } 207 | } 208 | 209 | // MARK: Overrides 210 | extension ColorSwatch { 211 | override func draw(_ dirtyRect: NSRect) { 212 | displayColor.drawSwatch(in: dirtyRect) 213 | updateBorder() 214 | } 215 | 216 | override func mouseDown(with event: NSEvent) { 217 | super.mouseDown(with: event) 218 | select() 219 | } 220 | } 221 | 222 | // MARK: Accessibility 223 | extension ColorSwatch { 224 | override func accessibilityLabel() -> String? { 225 | "color swatch" 226 | } 227 | 228 | override func accessibilityParent() -> Any? { 229 | context?.swatchView 230 | } 231 | 232 | override func accessibilityPerformPress() -> Bool { 233 | performAction() 234 | return true 235 | } 236 | 237 | override func accessibilityRole() -> NSAccessibility.Role? { 238 | .button 239 | } 240 | 241 | override func isAccessibilityElement() -> Bool { 242 | true 243 | } 244 | 245 | override func isAccessibilitySelected() -> Bool { 246 | isSelected 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Popover/ColorWellPopover.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellPopover.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A popover that contains a grid of selectable color swatches. 9 | class ColorWellPopover: NSPopover { 10 | private weak var context: ColorWellPopoverContext? 11 | 12 | init(context: ColorWellPopoverContext) { 13 | self.context = context 14 | super.init() 15 | contentViewController = context.popoverViewController 16 | behavior = .transient 17 | delegate = context.popoverViewController 18 | } 19 | 20 | @available(*, unavailable) 21 | required init?(coder: NSCoder) { 22 | fatalError("init(coder:) has not been implemented") 23 | } 24 | 25 | override func show( 26 | relativeTo positioningRect: NSRect, 27 | of positioningView: NSView, 28 | preferredEdge: NSRectEdge 29 | ) { 30 | super.show( 31 | relativeTo: positioningRect, 32 | of: positioningView, 33 | preferredEdge: preferredEdge 34 | ) 35 | 36 | guard let context else { 37 | return 38 | } 39 | 40 | context.containerView.window?.makeFirstResponder(nil) 41 | 42 | guard 43 | let color = context.colorWell?.color, 44 | let swatch = context.swatches.first(where: { $0.color.resembles(color) }) 45 | else { 46 | return 47 | } 48 | 49 | swatch.select() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Popover/ColorWellPopoverContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellPopoverContainerView.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A view that contains a grid of selectable color swatches. 9 | class ColorWellPopoverContainerView: NSView { 10 | private weak var context: ColorWellPopoverContext? 11 | 12 | init(context: ColorWellPopoverContext) { 13 | self.context = context 14 | 15 | super.init(frame: .zero) 16 | 17 | let layoutView = context.layoutView 18 | addSubview(layoutView) 19 | 20 | // Center the layout view inside the container. 21 | layoutView.translatesAutoresizingMaskIntoConstraints = false 22 | NSLayoutConstraint.activate([ 23 | layoutView.centerXAnchor.constraint(equalTo: centerXAnchor), 24 | layoutView.centerYAnchor.constraint(equalTo: centerYAnchor), 25 | ]) 26 | 27 | // Padding should vary based on the style. 28 | let padding: CGFloat 29 | switch context.colorWell?.style { 30 | case .swatches: 31 | padding = 15 32 | default: 33 | padding = 20 34 | } 35 | translatesAutoresizingMaskIntoConstraints = false 36 | NSLayoutConstraint.activate([ 37 | widthAnchor.constraint(equalTo: layoutView.widthAnchor, constant: padding), 38 | heightAnchor.constraint(equalTo: layoutView.heightAnchor, constant: padding), 39 | ]) 40 | } 41 | 42 | @available(*, unavailable) 43 | required init?(coder: NSCoder) { 44 | fatalError("init(coder:) has not been implemented") 45 | } 46 | } 47 | 48 | // MARK: Accessibility 49 | extension ColorWellPopoverContainerView { 50 | override func accessibilityChildren() -> [Any]? { 51 | context.map { [$0.layoutView] } 52 | } 53 | 54 | override func accessibilityRole() -> NSAccessibility.Role? { 55 | .group 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Popover/ColorWellPopoverContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellPopoverContext.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A central context for the elements of a color well's popover. 9 | class ColorWellPopoverContext { 10 | private(set) weak var colorWell: ColorWell? 11 | 12 | private(set) lazy var popover = ColorWellPopover(context: self) 13 | private(set) lazy var popoverViewController = ColorWellPopoverViewController(context: self) 14 | 15 | private(set) lazy var containerView = ColorWellPopoverContainerView(context: self) 16 | private(set) lazy var layoutView = ColorWellPopoverLayoutView(context: self) 17 | private(set) lazy var swatchView = ColorWellPopoverSwatchView(context: self) 18 | 19 | private(set) lazy var swatchCount = colorWell?.swatchColors.count ?? 0 20 | private(set) lazy var maxItemsPerRow = max(4, Int(Double(swatchCount).squareRoot().rounded(.up))) 21 | private(set) lazy var rowCount = Int((Double(swatchCount) / Double(maxItemsPerRow)).rounded(.up)) 22 | 23 | private(set) lazy var swatches: [ColorSwatch] = { 24 | guard let colorWell else { 25 | return [] 26 | } 27 | return colorWell.swatchColors.map { color in 28 | ColorSwatch(color: color, context: self) 29 | } 30 | }() 31 | 32 | /// Creates a context for the specified color well. 33 | init(colorWell: ColorWell) { 34 | self.colorWell = colorWell 35 | } 36 | 37 | /// Removes the strong reference to this instance from the color well. 38 | func removeStrongReference() { 39 | guard colorWell?.popoverContext === self else { 40 | return 41 | } 42 | colorWell?.popoverContext = nil 43 | } 44 | 45 | deinit { 46 | removeStrongReference() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Popover/ColorWellPopoverLayoutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellPopoverLayoutView.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A view that provides the layout for a color well's popover. 9 | class ColorWellPopoverLayoutView: NSGridView { 10 | /// A context that manages the elements of the layout view's popover. 11 | private weak var context: ColorWellPopoverContext? 12 | 13 | /// A button that, when pressed, activates the color well 14 | /// and closes the popover. 15 | var activationButton: ActionButton? { 16 | didSet { 17 | if let oldValue { 18 | oldValue.removeFromSuperview() 19 | oldValue.setAccessibilityParent(nil) 20 | if 21 | let cell = cell(for: oldValue), 22 | let row = cell.row 23 | { 24 | let rowIndex = index(of: row) 25 | removeRow(at: rowIndex) 26 | } 27 | } 28 | if let activationButton { 29 | addRow(with: [activationButton]) 30 | cell(for: activationButton)?.xPlacement = .fill 31 | activationButton.setAccessibilityParent(self) 32 | } 33 | } 34 | } 35 | 36 | /// Creates a layout view with the specified context. 37 | init(context: ColorWellPopoverContext) { 38 | self.context = context 39 | super.init(frame: .zero) 40 | addRow(with: [context.swatchView]) 41 | setActivationButtonIfNeeded() 42 | } 43 | 44 | @available(*, unavailable) 45 | required init?(coder: NSCoder) { 46 | fatalError("init(coder:) has not been implemented") 47 | } 48 | 49 | func setActivationButtonIfNeeded() { 50 | guard context?.colorWell?.style == .swatches else { 51 | return 52 | } 53 | activationButton = ActionButton(title: "Show More Colors…") { [weak context] in 54 | context?.colorWell?.activateAutoVerifyingExclusive() 55 | context?.popover.close() 56 | } 57 | activationButton?.bezelStyle = .recessed 58 | activationButton?.controlSize = .small 59 | } 60 | } 61 | 62 | // MARK: Accessibility 63 | extension ColorWellPopoverLayoutView { 64 | override func accessibilityParent() -> Any? { 65 | context?.containerView 66 | } 67 | 68 | override func accessibilityChildren() -> [Any]? { 69 | var result = [Any]() 70 | if let swatchView = context?.swatchView { 71 | result.append(swatchView) 72 | } 73 | if let activationButton { 74 | result.append(activationButton) 75 | } 76 | return result.isEmpty ? nil : result 77 | } 78 | 79 | override func accessibilityRole() -> NSAccessibility.Role? { 80 | .layoutArea 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Popover/ColorWellPopoverSwatchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellPopoverSwatchView.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A view that provides the layout for a popover's color swatches. 9 | class ColorWellPopoverSwatchView: NSGridView { 10 | private weak var context: ColorWellPopoverContext? 11 | 12 | var selectedSwatch: ColorSwatch? { 13 | context?.swatches.first { $0.isSelected } 14 | } 15 | 16 | init(context: ColorWellPopoverContext) { 17 | self.context = context 18 | 19 | super.init(frame: .zero) 20 | 21 | rowSpacing = 1 22 | columnSpacing = 1 23 | 24 | for row in makeRows() { 25 | addRow(with: row) 26 | } 27 | } 28 | 29 | @available(*, unavailable) 30 | required init?(coder: NSCoder) { 31 | fatalError("init(coder:) has not been implemented") 32 | } 33 | } 34 | 35 | // MARK: Instance Methods 36 | extension ColorWellPopoverSwatchView { 37 | private func makeRows() -> [[ColorSwatch]] { 38 | guard let context else { 39 | return [] 40 | } 41 | var currentRow = [ColorSwatch]() 42 | var rows = [[ColorSwatch]]() 43 | for swatch in context.swatches { 44 | if currentRow.count >= context.maxItemsPerRow { 45 | rows.append(currentRow) 46 | currentRow.removeAll() 47 | } 48 | currentRow.append(swatch) 49 | } 50 | if !currentRow.isEmpty { 51 | rows.append(currentRow) 52 | } 53 | return rows 54 | } 55 | } 56 | 57 | // MARK: Overrides 58 | extension ColorWellPopoverSwatchView { 59 | override func mouseDragged(with event: NSEvent) { 60 | super.mouseDragged(with: event) 61 | let swatch = context?.swatches.first { swatch in 62 | swatch.frameConvertedToWindow.contains(event.locationInWindow) 63 | } 64 | swatch?.select() 65 | } 66 | 67 | override func mouseUp(with event: NSEvent) { 68 | super.mouseUp(with: event) 69 | selectedSwatch?.performAction() 70 | } 71 | } 72 | 73 | // MARK: Accessibility 74 | extension ColorWellPopoverSwatchView { 75 | override func accessibilityParent() -> Any? { 76 | context?.layoutView 77 | } 78 | 79 | override func accessibilityChildren() -> [Any]? { 80 | context?.swatches 81 | } 82 | 83 | override func accessibilityRole() -> NSAccessibility.Role? { 84 | .layoutArea 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Popover/ColorWellPopoverViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellPopoverViewController.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A view controller that controls a color well popover's container view. 9 | class ColorWellPopoverViewController: NSViewController { 10 | private weak var context: ColorWellPopoverContext? 11 | 12 | init(context: ColorWellPopoverContext) { 13 | self.context = context 14 | super.init(nibName: nil, bundle: nil) 15 | self.view = context.containerView 16 | } 17 | 18 | @available(*, unavailable) 19 | required init?(coder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | } 23 | 24 | extension ColorWellPopoverViewController: NSPopoverDelegate { 25 | func popoverDidClose(_ notification: Notification) { 26 | // Async so that ColorWellSegment's mouseDown method 27 | // has a chance to run before the context becomes nil. 28 | DispatchQueue.main.async { [weak context] in 29 | context?.removeStrongReference() 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Segments/ColorWellBorderedSwatchSegment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellBorderedSwatchSegment.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A segment that displays a color swatch with the color well's 9 | /// current color selection, and that toggles the color panel 10 | /// when pressed. 11 | class ColorWellBorderedSwatchSegment: ColorWellSwatchSegment { 12 | 13 | // MARK: Properties 14 | 15 | private let cachedSwatchPath = Cache(NSBezierPath(), id: NSRect()) 16 | 17 | var bezelColor: NSColor { 18 | let bezelColor: NSColor 19 | 20 | switch state { 21 | case .highlight, .pressed: 22 | switch DrawingStyle.current { 23 | case .dark: 24 | bezelColor = .highlightColor 25 | case .light: 26 | bezelColor = .selectedColorWellSegmentColor 27 | } 28 | default: 29 | bezelColor = .colorWellSegmentColor 30 | } 31 | 32 | guard isEnabled else { 33 | let alphaComponent = max(bezelColor.alphaComponent - 0.5, 0.1) 34 | return bezelColor.withAlphaComponent(alphaComponent) 35 | } 36 | 37 | return bezelColor 38 | } 39 | 40 | override var side: Side { .null } 41 | 42 | // MARK: Initializers 43 | 44 | override init?(colorWell: ColorWell?) { 45 | super.init(colorWell: colorWell) 46 | cachedSwatchPath.updateConstructor { bounds in 47 | NSBezierPath( 48 | roundedRect: bounds.insetBy(dx: 3, dy: 3), 49 | xRadius: 2, 50 | yRadius: 2 51 | ) 52 | } 53 | } 54 | } 55 | 56 | // MARK: Perform Action 57 | extension ColorWellBorderedSwatchSegment { 58 | override class func performAction(for segment: ColorWellSegment) -> Bool { 59 | ColorWellToggleSegment.performAction(for: segment) 60 | } 61 | } 62 | 63 | // MARK: Overrides 64 | extension ColorWellBorderedSwatchSegment { 65 | override func drawSwatch(_ dirtyRect: NSRect) { 66 | bezelColor.setFill() 67 | caches.segmentPath.recache(id: dirtyRect) 68 | caches.segmentPath.cachedValue.fill() 69 | 70 | cachedSwatchPath.recache(id: dirtyRect) 71 | 72 | cachedSwatchPath.cachedValue.addClip() 73 | displayColor.drawSwatch(in: dirtyRect) 74 | 75 | borderColor.setStroke() 76 | cachedSwatchPath.cachedValue.stroke() 77 | } 78 | 79 | override func needsDisplayOnStateChange(_ state: State) -> Bool { 80 | switch state { 81 | case .highlight, .pressed, .default: 82 | return true 83 | case .hover: 84 | return false 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Segments/ColorWellPullDownSwatchSegment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellPullDownSwatchSegment.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | // MARK: - ColorWellPullDownSwatchSegment 9 | 10 | /// A segment that displays a color swatch with the color well's 11 | /// current color selection, and that triggers a pull down action 12 | /// when pressed. 13 | class ColorWellPullDownSwatchSegment: ColorWellSwatchSegment { 14 | 15 | // MARK: Properties 16 | 17 | /// The cached path for the segment's border. 18 | private let cachedBorderPath = Cache(NSBezierPath(), id: NSRect()) 19 | 20 | /// The cached paths for the segment's caret. 21 | private let cachedCaretPaths = Cache((caret: NSBezierPath(), backing: NSBezierPath()), id: NSRect()) 22 | 23 | /// A Boolean value indicating whether the segment can perform 24 | /// its pull down action. 25 | var canPullDown: Bool { 26 | guard let colorWell else { 27 | return false 28 | } 29 | return !colorWell.swatchColors.isEmpty 30 | } 31 | 32 | override var draggingInformation: DraggingInformation { 33 | didSet { 34 | // Hack to ensure the caret disappears when dragging starts. 35 | if draggingInformation.isDragging { 36 | state = .default 37 | } 38 | } 39 | } 40 | 41 | // MARK: Initializers 42 | 43 | override init?(colorWell: ColorWell?) { 44 | super.init(colorWell: colorWell) 45 | 46 | cachedBorderPath.updateConstructor { [weak self] bounds in 47 | guard let self else { 48 | return NSBezierPath() 49 | } 50 | let lineWidth = ColorWell.lineWidth 51 | let path = NSBezierPath.partialColorWellPath( 52 | rect: bounds.insetBy( 53 | dx: lineWidth / 4, 54 | dy: lineWidth / 2 55 | ), 56 | side: self.side 57 | ) 58 | path.lineWidth = lineWidth 59 | return path 60 | } 61 | 62 | cachedCaretPaths.updateConstructor { bounds in 63 | let lineWidth = 1.5 64 | 65 | let caretSize = NSSize(width: 12, height: 12) 66 | let caretBounds = NSRect( 67 | origin: NSPoint( 68 | x: bounds.maxX - caretSize.width - 4, 69 | y: bounds.midY - caretSize.height / 2 70 | ), 71 | size: caretSize 72 | ) 73 | let caretPathBounds = NSRect( 74 | x: 0, 75 | y: 0, 76 | width: (caretSize.width - lineWidth) / 2, 77 | height: (caretSize.height - lineWidth) / 4 78 | ).centered( 79 | in: caretBounds 80 | ).offsetBy( 81 | dx: 0, 82 | dy: -lineWidth / 4 83 | ) 84 | 85 | let caretPath = NSBezierPath() 86 | 87 | caretPath.lineWidth = lineWidth 88 | caretPath.lineCapStyle = .round 89 | 90 | caretPath.move( 91 | to: NSPoint( 92 | x: caretPathBounds.minX, 93 | y: caretPathBounds.maxY 94 | ) 95 | ) 96 | caretPath.line( 97 | to: NSPoint( 98 | x: caretPathBounds.midX, 99 | y: caretPathBounds.minY 100 | ) 101 | ) 102 | caretPath.line( 103 | to: NSPoint( 104 | x: caretPathBounds.maxX, 105 | y: caretPathBounds.maxY 106 | ) 107 | ) 108 | 109 | return (caretPath, NSBezierPath(ovalIn: caretBounds)) 110 | } 111 | } 112 | } 113 | 114 | // MARK: Static Methods 115 | extension ColorWellPullDownSwatchSegment { 116 | /// Returns a Boolean value indicating whether the specified 117 | /// segment can perform its pull down action. 118 | static func canPullDown(for segment: ColorWellSegment) -> Bool { 119 | guard let segment = segment as? Self else { 120 | return true 121 | } 122 | return segment.canPullDown 123 | } 124 | } 125 | 126 | // MARK: Instance Methods 127 | extension ColorWellPullDownSwatchSegment { 128 | /// Draws the segment's border in the given rectangle. 129 | private func drawBorder(_ dirtyRect: NSRect) { 130 | NSGraphicsContext.withCachedGraphicsState { 131 | cachedBorderPath.recache(id: dirtyRect) 132 | borderColor.setStroke() 133 | cachedBorderPath.cachedValue.stroke() 134 | } 135 | } 136 | 137 | /// Draws a downward-facing caret inside the segment's layer. 138 | /// 139 | /// This method is invoked when the mouse pointer is inside 140 | /// the bounds of the segment. 141 | private func drawCaret(_ dirtyRect: NSRect) { 142 | guard canPullDown else { 143 | return 144 | } 145 | 146 | NSGraphicsContext.withCachedGraphicsState { 147 | cachedCaretPaths.recache(id: dirtyRect) 148 | 149 | let paths = cachedCaretPaths.cachedValue 150 | 151 | NSColor(white: 0, alpha: 0.25).setFill() 152 | paths.backing.fill() 153 | 154 | NSColor.white.setStroke() 155 | paths.caret.stroke() 156 | } 157 | } 158 | } 159 | 160 | // MARK: Perform Action 161 | extension ColorWellPullDownSwatchSegment { 162 | override class func performAction(for segment: ColorWellSegment) -> Bool { 163 | guard let colorWell = segment.colorWell else { 164 | return false 165 | } 166 | 167 | if let popoverContext = colorWell.popoverContext { 168 | popoverContext.popover.close() 169 | return true 170 | } 171 | 172 | guard 173 | !NSEvent.modifierFlags.contains(.shift), 174 | canPullDown(for: segment) 175 | else { 176 | return ColorWellToggleSegment.performAction(for: segment) 177 | } 178 | 179 | let popoverContext = ColorWellPopoverContext(colorWell: colorWell) 180 | colorWell.popoverContext = popoverContext 181 | popoverContext.popover.show(relativeTo: segment.frame, of: segment, preferredEdge: .minY) 182 | 183 | return true 184 | } 185 | } 186 | 187 | // MARK: Overrides 188 | extension ColorWellPullDownSwatchSegment { 189 | override func draw(_ dirtyRect: NSRect) { 190 | super.draw(dirtyRect) 191 | drawBorder(dirtyRect) 192 | if state == .hover { 193 | drawCaret(dirtyRect) 194 | } 195 | } 196 | 197 | override func mouseEntered(with event: NSEvent) { 198 | super.mouseEntered(with: event) 199 | guard isEnabled else { 200 | return 201 | } 202 | state = .hover 203 | } 204 | 205 | override func mouseExited(with event: NSEvent) { 206 | super.mouseExited(with: event) 207 | guard isEnabled else { 208 | return 209 | } 210 | state = .default 211 | } 212 | 213 | override func needsDisplayOnStateChange(_ state: State) -> Bool { 214 | switch state { 215 | case .hover, .default: 216 | return true 217 | case .highlight, .pressed: 218 | return false 219 | } 220 | } 221 | 222 | override func updateTrackingAreas() { 223 | enum LocalCache { 224 | static let storage = Storage() 225 | } 226 | 227 | super.updateTrackingAreas() 228 | 229 | if let trackingArea = LocalCache.storage.value(forObject: self) { 230 | removeTrackingArea(trackingArea) 231 | } 232 | let trackingArea = NSTrackingArea( 233 | rect: bounds, 234 | options: [ 235 | .activeInKeyWindow, 236 | .mouseEnteredAndExited, 237 | ], 238 | owner: self 239 | ) 240 | addTrackingArea(trackingArea) 241 | 242 | LocalCache.storage.set(trackingArea, forObject: self) 243 | } 244 | } 245 | 246 | // MARK: - ColorWellSinglePullDownSwatchSegment 247 | 248 | /// A pull down swatch segment that fills its color well. 249 | class ColorWellSinglePullDownSwatchSegment: ColorWellPullDownSwatchSegment { 250 | override var side: Side { .null } 251 | } 252 | 253 | // MARK: - ColorWellPartialPullDownSwatchSegment 254 | 255 | /// A pull down swatch segment that does not fill its color well. 256 | class ColorWellPartialPullDownSwatchSegment: ColorWellPullDownSwatchSegment { 257 | override var side: Side { .left } 258 | } 259 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Segments/ColorWellSegment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellSegment.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A view that draws a segmented portion of a color well. 9 | class ColorWellSegment: NSView { 10 | 11 | // MARK: Properties 12 | 13 | weak var colorWell: ColorWell? 14 | 15 | /// The segment's cached values. 16 | let caches = ( 17 | segmentPath: Cache(NSBezierPath(), id: NSRect()), 18 | shadowLayer: Cache(CALayer(), id: NSRect()) 19 | ) 20 | 21 | /// The segment's current and previous states. 22 | var backingStates = (current: State.default, previous: State.default) 23 | 24 | /// The segment's current state. 25 | var state: State { 26 | get { 27 | backingStates.current 28 | } 29 | set { 30 | backingStates = (newValue, state) 31 | if needsDisplayOnStateChange(newValue) { 32 | needsDisplay = true 33 | } 34 | } 35 | } 36 | 37 | /// A Boolean value that indicates whether the segment's 38 | /// color well is active. 39 | var isActive: Bool { colorWell?.isActive ?? false } 40 | 41 | /// A Boolean value that indicates whether the segment's 42 | /// color well is enabled. 43 | var isEnabled: Bool { colorWell?.isEnabled ?? false } 44 | 45 | /// The side containing this segment in its color well. 46 | var side: Side { .null } 47 | 48 | /// The unaltered fill color of the segment. 49 | var rawColor: NSColor { .colorWellSegmentColor } 50 | 51 | /// The color that is displayed directly in the segment. 52 | var displayColor: NSColor { rawColor } 53 | 54 | // MARK: Initializers 55 | 56 | /// Creates a segment for the given color well. 57 | init?(colorWell: ColorWell?) { 58 | guard let colorWell else { 59 | return nil 60 | } 61 | super.init(frame: .zero) 62 | self.colorWell = colorWell 63 | wantsLayer = true 64 | updateCachedPathConstructors() 65 | } 66 | 67 | @available(*, unavailable) 68 | required init?(coder: NSCoder) { 69 | fatalError("init(coder:) has not been implemented") 70 | } 71 | 72 | // MARK: Dynamic Class Methods 73 | 74 | /// Invoked to perform an action for the given segment. 75 | @objc dynamic 76 | class func performAction(for segment: ColorWellSegment) -> Bool { false } 77 | 78 | // MARK: Dynamic Instance Methods 79 | 80 | /// Invoked to return whether the segment should be redrawn 81 | /// after its state changes. 82 | @objc dynamic 83 | func needsDisplayOnStateChange(_ state: State) -> Bool { false } 84 | } 85 | 86 | // MARK: Instance Methods 87 | extension ColorWellSegment { 88 | private func updateCachedPathConstructors() { 89 | caches.segmentPath.updateConstructor { [weak self] bounds in 90 | guard let self else { 91 | return NSBezierPath() 92 | } 93 | return .partialColorWellPath(rect: bounds, side: self.side) 94 | } 95 | 96 | caches.shadowLayer.updateConstructor { [weak self] bounds in 97 | guard let self else { 98 | return CALayer() 99 | } 100 | 101 | let shadowRadius = 0.75 102 | let shadowOffset = CGSize(width: 0, height: -0.25) 103 | 104 | let shadowPath = CGPath.partialColorWellPath(rect: bounds, side: self.side) 105 | let maskPath = CGMutablePath() 106 | maskPath.addRect( 107 | bounds.insetBy( 108 | dx: -(shadowRadius * 2) + shadowOffset.width, 109 | dy: -(shadowRadius * 2) + shadowOffset.height 110 | ) 111 | ) 112 | maskPath.addPath(shadowPath) 113 | maskPath.closeSubpath() 114 | 115 | let maskLayer = CAShapeLayer() 116 | maskLayer.path = maskPath 117 | maskLayer.fillRule = .evenOdd 118 | 119 | let shadowLayer = CALayer() 120 | shadowLayer.shadowRadius = shadowRadius 121 | shadowLayer.shadowOffset = shadowOffset 122 | shadowLayer.shadowPath = shadowPath 123 | shadowLayer.shadowOpacity = 0.5 124 | shadowLayer.mask = maskLayer 125 | 126 | return shadowLayer 127 | } 128 | } 129 | 130 | /// Updates the shadow layer for the specified rectangle. 131 | func updateShadowLayer(_ dirtyRect: NSRect) { 132 | caches.shadowLayer.cachedValue.removeFromSuperlayer() 133 | 134 | guard let layer else { 135 | return 136 | } 137 | 138 | layer.masksToBounds = false 139 | 140 | caches.shadowLayer.recache(id: dirtyRect) 141 | layer.addSublayer(caches.shadowLayer.cachedValue) 142 | } 143 | } 144 | 145 | // MARK: Overrides 146 | extension ColorWellSegment { 147 | override func draw(_ dirtyRect: NSRect) { 148 | displayColor.setFill() 149 | caches.segmentPath.recache(id: dirtyRect) 150 | caches.segmentPath.cachedValue.fill() 151 | updateShadowLayer(dirtyRect) 152 | } 153 | 154 | override func mouseDown(with event: NSEvent) { 155 | super.mouseDown(with: event) 156 | guard isEnabled else { 157 | return 158 | } 159 | state = .highlight 160 | } 161 | 162 | override func mouseUp(with event: NSEvent) { 163 | super.mouseUp(with: event) 164 | guard 165 | isEnabled, 166 | frameConvertedToWindow.contains(event.locationInWindow) 167 | else { 168 | return 169 | } 170 | _ = Self.performAction(for: self) 171 | } 172 | } 173 | 174 | // MARK: Accessibility 175 | extension ColorWellSegment { 176 | override func accessibilityParent() -> Any? { 177 | colorWell 178 | } 179 | 180 | override func accessibilityPerformPress() -> Bool { 181 | Self.performAction(for: self) 182 | } 183 | 184 | override func accessibilityRole() -> NSAccessibility.Role? { 185 | .button 186 | } 187 | 188 | override func isAccessibilityElement() -> Bool { 189 | true 190 | } 191 | } 192 | 193 | // MARK: ColorWellSegment.State 194 | extension ColorWellSegment { 195 | /// A type that represents the state of a color well segment. 196 | @objc enum State: Int { 197 | /// The segment is being hovered over. 198 | case hover 199 | 200 | /// The segment is highlighted. 201 | case highlight 202 | 203 | /// The segment is pressed. 204 | case pressed 205 | 206 | /// The default, idle state of a segment. 207 | case `default` 208 | } 209 | } 210 | 211 | // MARK: ColorWellSegment.DraggingInformation 212 | extension ColorWellSegment { 213 | /// Dragging information associated with a color well segment. 214 | struct DraggingInformation { 215 | /// The default values for this instance. 216 | private let defaults: (threshold: CGFloat, isDragging: Bool, offset: CGSize) 217 | 218 | /// The amount of movement that must occur before a dragging 219 | /// session can start. 220 | var threshold: CGFloat 221 | 222 | /// A Boolean value that indicates whether a drag is currently 223 | /// in progress. 224 | var isDragging: Bool 225 | 226 | /// The accumulated offset of the current series of dragging 227 | /// events. 228 | var offset: CGSize 229 | 230 | /// A Boolean value that indicates whether the current dragging 231 | /// information is valid for starting a dragging session. 232 | var isValid: Bool { 233 | hypot(offset.width, offset.height) >= threshold 234 | } 235 | 236 | /// Creates an instance with the given values. 237 | /// 238 | /// The values that are provided here will be cached, and used 239 | /// to reset the instance. 240 | init( 241 | threshold: CGFloat = 4, 242 | isDragging: Bool = false, 243 | offset: CGSize = CGSize() 244 | ) { 245 | self.defaults = (threshold, isDragging, offset) 246 | self.threshold = threshold 247 | self.isDragging = isDragging 248 | self.offset = offset 249 | } 250 | 251 | /// Resets the dragging information to its default values. 252 | mutating func reset() { 253 | self = Self( 254 | threshold: defaults.threshold, 255 | isDragging: defaults.isDragging, 256 | offset: defaults.offset 257 | ) 258 | } 259 | 260 | /// Updates the segment's dragging offset according to the x and y 261 | /// deltas of the given event. 262 | mutating func updateOffset(with event: NSEvent) { 263 | offset.width += event.deltaX 264 | offset.height += event.deltaY 265 | } 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Segments/ColorWellSwatchSegment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellSwatchSegment.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A segment that displays a color swatch with the color well's 9 | /// current color selection. 10 | class ColorWellSwatchSegment: ColorWellSegment { 11 | 12 | // MARK: Properties 13 | 14 | var draggingInformation = DraggingInformation() 15 | 16 | var borderColor: NSColor { 17 | let displayColor = displayColor 18 | let normalizedBrightness = min(displayColor.averageBrightness, displayColor.alphaComponent) 19 | let alpha = min(normalizedBrightness, 0.2) 20 | return NSColor(white: 1 - alpha, alpha: alpha) 21 | } 22 | 23 | override var rawColor: NSColor { 24 | colorWell?.color ?? super.rawColor 25 | } 26 | 27 | override var displayColor: NSColor { 28 | super.displayColor.usingColorSpace(.displayP3) ?? super.displayColor 29 | } 30 | 31 | // MARK: Initializers 32 | 33 | override init?(colorWell: ColorWell?) { 34 | super.init(colorWell: colorWell) 35 | registerForDraggedTypes([.color]) 36 | } 37 | } 38 | 39 | // MARK: Instance Methods 40 | extension ColorWellSwatchSegment { 41 | /// Draws the segment's swatch in the specified rectangle. 42 | @objc dynamic 43 | func drawSwatch(_ dirtyRect: NSRect) { 44 | caches.segmentPath.recache(id: dirtyRect) 45 | NSImage.drawSwatch( 46 | with: displayColor, 47 | in: dirtyRect, 48 | clippingTo: caches.segmentPath.cachedValue 49 | ) 50 | } 51 | } 52 | 53 | // MARK: Overrides 54 | extension ColorWellSwatchSegment { 55 | override func draw(_ dirtyRect: NSRect) { 56 | drawSwatch(dirtyRect) 57 | updateShadowLayer(dirtyRect) 58 | } 59 | 60 | override func mouseDown(with event: NSEvent) { 61 | super.mouseDown(with: event) 62 | draggingInformation.reset() 63 | } 64 | 65 | override func mouseUp(with event: NSEvent) { 66 | defer { 67 | draggingInformation.reset() 68 | } 69 | guard !draggingInformation.isDragging else { 70 | return 71 | } 72 | super.mouseUp(with: event) 73 | } 74 | 75 | override func mouseDragged(with event: NSEvent) { 76 | super.mouseDragged(with: event) 77 | 78 | guard isEnabled else { 79 | return 80 | } 81 | 82 | draggingInformation.updateOffset(with: event) 83 | 84 | guard 85 | draggingInformation.isValid, 86 | let color = colorWell?.color 87 | else { 88 | return 89 | } 90 | 91 | draggingInformation.isDragging = true 92 | state = backingStates.previous 93 | 94 | let colorForDragging = color.createArchivedCopy() 95 | NSColorPanel.dragColor(colorForDragging, with: event, from: self) 96 | } 97 | 98 | override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { 99 | guard 100 | isEnabled, 101 | let types = sender.draggingPasteboard.types, 102 | types.contains(where: { registeredDraggedTypes.contains($0) }) 103 | else { 104 | return [] 105 | } 106 | return .move 107 | } 108 | 109 | override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { 110 | if 111 | let colorWell, 112 | let color = NSColor(from: sender.draggingPasteboard) 113 | { 114 | colorWell.color = color 115 | return true 116 | } 117 | return false 118 | } 119 | } 120 | 121 | // MARK: Accessibility 122 | extension ColorWellSwatchSegment { 123 | override func isAccessibilityElement() -> Bool { 124 | false 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Segments/ColorWellToggleSegment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellToggleSegment.swift 3 | // ColorWell 4 | // 5 | 6 | import Cocoa 7 | 8 | // MARK: - ColorWellToggleSegment 9 | 10 | /// A segment that toggles the color panel when pressed. 11 | class ColorWellToggleSegment: ColorWellSegment { 12 | 13 | // MARK: Static Properties 14 | 15 | static let widthConstant: CGFloat = 20 16 | 17 | // MARK: Instance Properties 18 | 19 | private let cachedImageLayer = Cache(CALayer(), id: ImageLayerCacheID()) 20 | 21 | override var side: Side { .right } 22 | 23 | override var rawColor: NSColor { 24 | switch state { 25 | case .highlight: 26 | return .highlightedColorWellSegmentColor 27 | case .pressed: 28 | return .selectedColorWellSegmentColor 29 | default: 30 | return .colorWellSegmentColor 31 | } 32 | } 33 | 34 | // MARK: Initializers 35 | 36 | override init?(colorWell: ColorWell?) { 37 | super.init(colorWell: colorWell) 38 | // Constraining this segment's width will force the other 39 | // segment to fill the remaining space. 40 | translatesAutoresizingMaskIntoConstraints = false 41 | widthAnchor.constraint(equalToConstant: Self.widthConstant).isActive = true 42 | 43 | cachedImageLayer.updateConstructor { id in 44 | enum LocalCache { 45 | private static let defaultImage: NSImage = { 46 | // Force unwrap is okay here, as the image is an AppKit builtin. 47 | // swiftlint:disable:next force_unwrapping 48 | let image = NSImage(named: NSImage.touchBarColorPickerFillName)! 49 | return image.clippedToSquare() 50 | }() 51 | 52 | private static let enabledTintedForDarkAppearance: Any = { 53 | let image = defaultImage.tinted(to: .white, amount: 0.33) 54 | let scale = image.recommendedLayerContentsScale(0.0) 55 | return image.layerContents(forContentsScale: scale) 56 | }() 57 | 58 | private static let enabledTintedForLightAppearance: Any = { 59 | let image = defaultImage.tinted(to: .black, amount: 0.20) 60 | let scale = image.recommendedLayerContentsScale(0.0) 61 | return image.layerContents(forContentsScale: scale) 62 | }() 63 | 64 | private static let disabledTintedForDarkAppearance: Any = { 65 | let image = NSImage(size: defaultImage.size, flipped: false) { bounds in 66 | defaultImage 67 | .tinted(to: .gray, amount: 0.33) 68 | .draw(in: bounds, from: bounds, operation: .copy, fraction: 0.5) 69 | return true 70 | } 71 | let scale = image.recommendedLayerContentsScale(0.0) 72 | return image.layerContents(forContentsScale: scale) 73 | }() 74 | 75 | private static let disabledTintedForLightAppearance: Any = { 76 | let image = NSImage(size: defaultImage.size, flipped: false) { bounds in 77 | defaultImage 78 | .tinted(to: .gray, amount: 0.20) 79 | .draw(in: bounds, from: bounds, operation: .copy, fraction: 0.5) 80 | return true 81 | } 82 | let scale = image.recommendedLayerContentsScale(0.0) 83 | return image.layerContents(forContentsScale: scale) 84 | }() 85 | 86 | static let defaultContents: Any = { 87 | let scale = defaultImage.recommendedLayerContentsScale(0.0) 88 | return defaultImage.layerContents(forContentsScale: scale) 89 | }() 90 | 91 | static func tintedForDarkAppearance(_ isEnabled: Bool) -> Any { 92 | guard isEnabled else { 93 | return disabledTintedForDarkAppearance 94 | } 95 | return enabledTintedForDarkAppearance 96 | } 97 | 98 | static func tintedForLightAppearance(_ isEnabled: Bool) -> Any { 99 | guard isEnabled else { 100 | return disabledTintedForLightAppearance 101 | } 102 | return enabledTintedForLightAppearance 103 | } 104 | } 105 | 106 | let dimension = min(id.dirtyRect.width, id.dirtyRect.height) - 6 107 | let imageLayer = CALayer() 108 | 109 | imageLayer.frame = NSRect( 110 | x: 0, 111 | y: 0, 112 | width: dimension, 113 | height: dimension 114 | ).centered(in: id.dirtyRect) 115 | 116 | if id.state == .highlight || !id.isEnabled { 117 | switch DrawingStyle.current { 118 | case .dark: 119 | imageLayer.contents = LocalCache.tintedForDarkAppearance(id.isEnabled) 120 | case .light: 121 | imageLayer.contents = LocalCache.tintedForLightAppearance(id.isEnabled) 122 | } 123 | } else { 124 | imageLayer.contents = LocalCache.defaultContents 125 | } 126 | 127 | return imageLayer 128 | } 129 | } 130 | } 131 | 132 | // MARK: Instance Methods 133 | extension ColorWellToggleSegment { 134 | /// Adds a layer that contains an image indicating that the 135 | /// segment toggles the color panel. 136 | private func updateImageLayer(_ dirtyRect: NSRect) { 137 | cachedImageLayer.cachedValue.removeFromSuperlayer() 138 | guard let layer else { 139 | return 140 | } 141 | cachedImageLayer.recache(id: ImageLayerCacheID(dirtyRect, segment: self)) 142 | layer.addSublayer(cachedImageLayer.cachedValue) 143 | } 144 | } 145 | 146 | // MARK: Perform Action 147 | extension ColorWellToggleSegment { 148 | override class func performAction(for segment: ColorWellSegment) -> Bool { 149 | guard let colorWell = segment.colorWell else { 150 | return false 151 | } 152 | if colorWell.isActive { 153 | colorWell.deactivate() 154 | } else { 155 | colorWell.activateAutoVerifyingExclusive() 156 | } 157 | return true 158 | } 159 | } 160 | 161 | // MARK: Overrides 162 | extension ColorWellToggleSegment { 163 | override func draw(_ dirtyRect: NSRect) { 164 | super.draw(dirtyRect) 165 | updateImageLayer(dirtyRect) 166 | } 167 | 168 | override func mouseDragged(with event: NSEvent) { 169 | super.mouseDragged(with: event) 170 | 171 | guard isEnabled else { 172 | return 173 | } 174 | 175 | if frameConvertedToWindow.contains(event.locationInWindow) { 176 | state = .highlight 177 | } else if isActive { 178 | state = .pressed 179 | } else { 180 | state = .default 181 | } 182 | } 183 | 184 | override func needsDisplayOnStateChange(_ state: State) -> Bool { 185 | switch state { 186 | case .highlight, .pressed, .default: 187 | return true 188 | case .hover: 189 | return false 190 | } 191 | } 192 | } 193 | 194 | // MARK: Accessibility 195 | extension ColorWellToggleSegment { 196 | override func accessibilityLabel() -> String? { 197 | "color picker" 198 | } 199 | } 200 | 201 | // MARK: ColorWellToggleSegment.ImageLayerCacheID 202 | extension ColorWellToggleSegment { 203 | private struct ImageLayerCacheID: Equatable { 204 | let dirtyRect: NSRect 205 | let state: State 206 | let isEnabled: Bool 207 | 208 | init() { 209 | self.dirtyRect = .zero 210 | self.state = .default 211 | self.isEnabled = true 212 | } 213 | 214 | init(_ dirtyRect: NSRect, segment: ColorWellToggleSegment) { 215 | self.dirtyRect = dirtyRect 216 | self.state = segment.state 217 | self.isEnabled = segment.isEnabled 218 | } 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/Cocoa/Style.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Style.swift 3 | // ColorWell 4 | // 5 | 6 | import Foundation 7 | 8 | extension ColorWell { 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 segmented control that displays 12 | /// the selected color alongside a dedicated button to show the system 13 | /// color panel. 14 | /// 15 | /// Clicking inside the color area displays a popover containing the 16 | /// color well's ``ColorWell/ColorWell/swatchColors``. 17 | case expanded = 0 18 | 19 | /// The color well is displayed as a rectangular control that displays 20 | /// the selected color and shows a popover containing the color well's 21 | /// ``ColorWell/ColorWell/swatchColors`` when clicked. 22 | /// 23 | /// The popover contains an option to show the system color panel. 24 | case swatches = 1 25 | 26 | /// The color well is displayed as a rectangular control that displays 27 | /// the selected color and shows the system color panel when clicked. 28 | case colorPanel = 2 29 | 30 | /// The color well is displayed as a rectangular control that displays 31 | /// the selected color and shows the system color panel when clicked. 32 | /// 33 | /// Equivalent to ``colorPanel``. 34 | public static let standard = Self.colorPanel 35 | } 36 | } 37 | 38 | extension ColorWell.Style: CustomStringConvertible { 39 | public var description: String { 40 | let prefix = String(describing: Self.self) + "." 41 | switch self { 42 | case .expanded: 43 | return prefix + "expanded" 44 | case .swatches: 45 | return prefix + "swatches" 46 | case .colorPanel: 47 | return prefix + "colorPanel" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/SwiftUI/ColorWellConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellConfiguration.swift 3 | // ColorWell 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import SwiftUI 8 | 9 | // MARK: - ColorWellConfiguration 10 | 11 | /// A type containing information used to configure a color well. 12 | @available(macOS 10.15, *) 13 | struct ColorWellConfiguration { 14 | 15 | // MARK: Properties 16 | 17 | /// The color well's initial color value. 18 | let color: NSColor? 19 | 20 | /// An optional action to add to the color well. 21 | let action: ((NSColor) -> Void)? 22 | 23 | /// An optional label view that is displayed adjacent to 24 | /// the color well. 25 | let label: AnyView? 26 | 27 | /// A closure that informs the color well whether or not 28 | /// it should support alpha values. 29 | let updateShowsAlpha: (ColorWell) -> Void 30 | 31 | // MARK: Initializers 32 | 33 | /// Creates a configuration using the specified modifiers. 34 | /// 35 | /// If more than one of the same modifier is provided, the 36 | /// one which occurs last will be used. 37 | init(modifiers: [Modifier?]) { 38 | typealias Values = ( 39 | color: NSColor?, 40 | action: ((NSColor) -> Void)?, 41 | label: AnyView?, 42 | updateShowsAlpha: (ColorWell) -> Void 43 | ) 44 | 45 | var values: Values = (nil, nil, nil, { _ in }) 46 | 47 | for case .some(let modifier) in modifiers { 48 | switch modifier { 49 | case .color(let color): 50 | values.color = color 51 | case .action(let action): 52 | values.action = action 53 | case .label(let label): 54 | values.label = label.erased() 55 | case .showsAlpha(let showsAlpha): 56 | values.updateShowsAlpha = { colorWell in 57 | colorWell.showsAlphaForcedState = showsAlpha.wrappedValue 58 | } 59 | } 60 | } 61 | 62 | self.color = values.color 63 | self.action = values.action 64 | self.label = values.label 65 | self.updateShowsAlpha = values.updateShowsAlpha 66 | } 67 | } 68 | 69 | // MARK: ColorWellConfiguration.Modifier 70 | @available(macOS 10.15, *) 71 | extension ColorWellConfiguration { 72 | /// A type that modifies a value in a `ColorWellConfiguration`. 73 | enum Modifier { 74 | /// Sets the configuration's color to the given value. 75 | case color(NSColor) 76 | 77 | /// Sets the configuration's action to the given closure. 78 | case action((NSColor) -> Void) 79 | 80 | /// Sets the configuration's label to the given view. 81 | case label(any View) 82 | 83 | /// Sets the value of the configuration's `showsAlpha` 84 | /// property to the value stored by the given binding. 85 | case showsAlpha(Binding) 86 | } 87 | } 88 | 89 | // MARK: Modifier Constructors 90 | @available(macOS 10.15, *) 91 | extension ColorWellConfiguration.Modifier { 92 | /// Sets the configuration's color to the given value. 93 | @available(macOS 11.0, *) 94 | static func color(_ color: Color) -> Self { 95 | Self.color(NSColor(color)) 96 | } 97 | 98 | /// Sets the configuration's color to the given value. 99 | /// 100 | /// - Note: If the conversion from `CGColor` to `NSColor` 101 | /// fails, this modifier will return `nil`. 102 | static func cgColor(_ cgColor: CGColor) -> Self? { 103 | NSColor(cgColor: cgColor).map(Self.color) 104 | } 105 | 106 | /// Sets the configuration's action to the given closure. 107 | static func action(_ action: @escaping (Color) -> Void) -> Self { 108 | Self.action { color in 109 | action(Color(color)) 110 | } 111 | } 112 | 113 | /// Sets the configuration's action to the given closure. 114 | static func action(_ action: @escaping (CGColor) -> Void) -> Self { 115 | Self.action { color in 116 | action(color.cgColor) 117 | } 118 | } 119 | 120 | /// Sets the configuration's label to the view returned from 121 | /// the given closure. 122 | static func label(_ label: () -> any View) -> Self { 123 | Self.label(label()) 124 | } 125 | 126 | /// Sets the configuration's label to a text view constructed 127 | /// using the given string. 128 | static func title(_ title: S) -> Self { 129 | Self.label(Text(title)) 130 | } 131 | 132 | /// Sets the configuration's label to a text view constructed 133 | /// using the given localized string key. 134 | static func titleKey(_ titleKey: LocalizedStringKey) -> Self { 135 | Self.label(Text(titleKey)) 136 | } 137 | 138 | /// Sets the value of the configuration's `showsAlpha` property 139 | /// to a constant binding derived from the given Boolean value. 140 | static func supportsOpacity(_ supportsOpacity: Bool) -> Self { 141 | Self.showsAlpha(.constant(supportsOpacity)) 142 | } 143 | } 144 | #endif 145 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/SwiftUI/ColorWellRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellRepresentable.swift 3 | // ColorWell 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import SwiftUI 8 | 9 | // MARK: - BridgedColorWell 10 | 11 | /// A custom color well subclass that makes minor changes 12 | /// to match SwiftUI's `ColorPicker`. 13 | class BridgedColorWell: ColorWell { 14 | override var customIntrinsicContentSize: NSSize { 15 | switch style { 16 | case .expanded, .swatches: 17 | return super.customIntrinsicContentSize 18 | case .colorPanel: 19 | return super.customIntrinsicContentSize.insetBy(dx: -3, dy: 0.5) 20 | } 21 | } 22 | 23 | convenience init(color: NSColor, style: Style) { 24 | self.init(frame: Self.defaultFrame, color: color, style: style) 25 | } 26 | } 27 | 28 | // MARK: - ColorWellRepresentable 29 | 30 | /// An `NSViewRepresentable` wrapper around a `ColorWell`. 31 | @available(macOS 10.15, *) 32 | struct ColorWellRepresentable: NSViewRepresentable { 33 | /// The configuration used to create the color well. 34 | let configuration: ColorWellConfiguration 35 | 36 | /// Creates and returns this view's underlying color well. 37 | func makeNSView(context: Context) -> ColorWell { 38 | guard let color = configuration.color else { 39 | guard let style = context.environment.colorWellStyleConfiguration.style else { 40 | return BridgedColorWell() 41 | } 42 | return BridgedColorWell(style: style) 43 | } 44 | guard let style = context.environment.colorWellStyleConfiguration.style else { 45 | return BridgedColorWell(color: color) 46 | } 47 | return BridgedColorWell(color: color, style: style) 48 | } 49 | 50 | /// Updates the color well's configuration to the most recent 51 | /// values stored in the environment. 52 | func updateNSView(_ colorWell: ColorWell, context: Context) { 53 | updateStyle(colorWell, context: context) 54 | updateChangeHandlers(colorWell, context: context) 55 | updateSwatchColors(colorWell, context: context) 56 | updateIsEnabled(colorWell, context: context) 57 | configuration.updateShowsAlpha(colorWell) 58 | } 59 | 60 | /// Updates the color well's style to the most recent configuration 61 | /// stored in the environment. 62 | func updateStyle(_ colorWell: ColorWell, context: Context) { 63 | if let style = context.environment.colorWellStyleConfiguration.style { 64 | colorWell.style = style 65 | } 66 | } 67 | 68 | /// Updates the color well's change handlers to the most recent 69 | /// value stored in the environment. 70 | func updateChangeHandlers(_ colorWell: ColorWell, context: Context) { 71 | // If an action was added to the configuration, it can only have 72 | // happened on initialization, so it should come first. 73 | var changeHandlers = Array(compacting: [configuration.action]) 74 | 75 | // @ViewBuilder blocks are evaluated from the outside in. This causes 76 | // the change handlers that were added nearest to the color well in 77 | // the view hierarchy to be added last in the environment. Reversing 78 | // the stored handlers returns the correct order. 79 | changeHandlers += context.environment.changeHandlers.reversed() 80 | 81 | // Overwrite the current change handlers. DO NOT APPEND, or more and 82 | // more duplicate actions will be added every time the view updates. 83 | colorWell.changeHandlers = changeHandlers 84 | } 85 | 86 | /// Updates the color well's swatch colors to the most recent 87 | /// value stored in the environment. 88 | func updateSwatchColors(_ colorWell: ColorWell, context: Context) { 89 | if let swatchColors = context.environment.swatchColors { 90 | colorWell.swatchColors = swatchColors 91 | } 92 | } 93 | 94 | /// Updates the color well's `isEnabled` value to the most recent 95 | /// value stored in the environment. 96 | func updateIsEnabled(_ colorWell: ColorWell, context: Context) { 97 | colorWell.isEnabled = context.environment.isEnabled 98 | } 99 | } 100 | #endif 101 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/SwiftUI/ColorWellStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellStyle.swift 3 | // ColorWell 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | 8 | // MARK: - ColorWellStyleConfiguration 9 | 10 | /// Values that configure a color well's style. 11 | public struct _ColorWellStyleConfiguration { 12 | /// The underlying style of the color well. 13 | var style: ColorWell.Style? 14 | } 15 | 16 | extension _ColorWellStyleConfiguration: CustomStringConvertible { 17 | public var description: String { 18 | style.map(String.init) ?? "nil" 19 | } 20 | } 21 | 22 | // MARK: - ColorWellStyle 23 | 24 | /// A type that specifies the appearance and behavior of a color well. 25 | public protocol ColorWellStyle { 26 | /// Values that configure the color well's style. 27 | var _configuration: _ColorWellStyleConfiguration { get } 28 | } 29 | 30 | // MARK: - ExpandedColorWellStyle 31 | 32 | /// A color well style that displays the color well's color alongside 33 | /// a dedicated button that toggles the system color panel. 34 | /// 35 | /// Clicking inside the color area displays a popover containing the 36 | /// color well's swatch colors. 37 | /// 38 | /// You can also use ``expanded`` to construct this style. 39 | public struct ExpandedColorWellStyle: ColorWellStyle { 40 | public let _configuration = _ColorWellStyleConfiguration(style: .expanded) 41 | 42 | /// Creates an instance of the expanded color well style. 43 | public init() { } 44 | } 45 | 46 | extension ColorWellStyle where Self == ExpandedColorWellStyle { 47 | /// A color well style that displays the color well's color alongside 48 | /// a dedicated button that toggles the system color panel. 49 | /// 50 | /// Clicking inside the color area displays a popover containing the 51 | /// color well's swatch colors. 52 | public static var expanded: ExpandedColorWellStyle { 53 | ExpandedColorWellStyle() 54 | } 55 | } 56 | 57 | // MARK: - SwatchesColorWellStyle 58 | 59 | /// A color well style that displays the color well's color inside of a 60 | /// rectangular control, and shows a popover containing the color well's 61 | /// swatch colors when clicked. 62 | /// 63 | /// You can also use ``swatches`` to construct this style. 64 | public struct SwatchesColorWellStyle: ColorWellStyle { 65 | public let _configuration = _ColorWellStyleConfiguration(style: .swatches) 66 | 67 | /// Creates an instance of the swatches color well style. 68 | public init() { } 69 | } 70 | 71 | extension ColorWellStyle where Self == SwatchesColorWellStyle { 72 | /// A color well style that displays the color well's color inside of a 73 | /// rectangular control, and shows a popover containing the color well's 74 | /// swatch colors when clicked. 75 | public static var swatches: SwatchesColorWellStyle { 76 | SwatchesColorWellStyle() 77 | } 78 | } 79 | 80 | // MARK: - StandardColorWellStyle 81 | 82 | /// A color well style that displays the color well's color inside of a 83 | /// rectangular control, and toggles the system color panel when clicked. 84 | /// 85 | /// You can also use ``standard`` to construct this style. 86 | public struct StandardColorWellStyle: ColorWellStyle { 87 | public let _configuration = _ColorWellStyleConfiguration(style: .standard) 88 | 89 | /// Creates an instance of the standard color well style. 90 | public init() { } 91 | } 92 | 93 | extension ColorWellStyle where Self == StandardColorWellStyle { 94 | /// A color well style that displays the color well's color inside of a 95 | /// rectangular control, and toggles the system color panel when clicked. 96 | public static var standard: StandardColorWellStyle { 97 | StandardColorWellStyle() 98 | } 99 | } 100 | #endif 101 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/SwiftUI/ColorWellView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellView.swift 3 | // ColorWell 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import SwiftUI 8 | 9 | /// A SwiftUI view that displays a user-selectable color value. 10 | /// 11 | /// Color wells provide a means for choosing custom colors directly within 12 | /// your app's user interface. A color well displays the currently selected 13 | /// color, and provides options for selecting new colors. There are a number 14 | /// of styles to choose from, each of which provides a different appearance 15 | /// and set of behaviors. 16 | @available(macOS 10.15, *) 17 | public struct ColorWellView: View { 18 | /// A type-erased optional label that is displayed adjacent 19 | /// to the color well. 20 | private let label: AnyView? 21 | 22 | /// A type-erased `NSViewRepresentable` wrapper around the 23 | /// view's underlying color well. 24 | private let representable: AnyView 25 | 26 | /// The content view of the color well. 27 | public var body: some View { 28 | if let label { 29 | HStack(alignment: .center) { 30 | label 31 | representable 32 | } 33 | } else { 34 | representable 35 | } 36 | } 37 | 38 | /// Creates a color well view using the specified configuration. 39 | init(configuration: ColorWellConfiguration) { 40 | label = configuration.label 41 | representable = ColorWellRepresentable(configuration: configuration) 42 | .fixedSize() 43 | .erased() 44 | } 45 | } 46 | 47 | // MARK: ColorWellView (Label: View) 48 | @available(macOS 10.15, *) 49 | extension ColorWellView { 50 | /// Creates a color well that uses the provided view as its label, 51 | /// and executes the given action when its color changes. 52 | /// 53 | /// - Parameters: 54 | /// - supportsOpacity: A Boolean value that indicates whether 55 | /// the color well allows adjusting the selected color's opacity; 56 | /// the default is true. 57 | /// - label: A view that describes the purpose of the color well. 58 | /// - action: An action to perform when the color well's color changes. 59 | public init( 60 | supportsOpacity: Bool = true, 61 | @ViewBuilder label: () -> Label, 62 | action: @escaping (Color) -> Void 63 | ) { 64 | self.init( 65 | configuration: ColorWellConfiguration( 66 | modifiers: [ 67 | .supportsOpacity(supportsOpacity), 68 | .label(label), 69 | .action(action), 70 | ] 71 | ) 72 | ) 73 | } 74 | 75 | /// Creates a color well with an initial color value, with the provided 76 | /// view being used as the color well's label. 77 | /// 78 | /// - Parameters: 79 | /// - color: The initial value of the color well's color. 80 | /// - supportsOpacity: A Boolean value that indicates whether 81 | /// the color well allows adjusting the selected color's opacity; 82 | /// the default is true. 83 | /// - label: A view that describes the purpose of the color well. 84 | @available(macOS 11.0, *) 85 | public init( 86 | color: Color, 87 | supportsOpacity: Bool = true, 88 | @ViewBuilder label: () -> Label 89 | ) { 90 | self.init( 91 | configuration: ColorWellConfiguration( 92 | modifiers: [ 93 | .color(color), 94 | .supportsOpacity(supportsOpacity), 95 | .label(label), 96 | ] 97 | ) 98 | ) 99 | } 100 | 101 | /// Creates a color well with an initial color value, with the provided 102 | /// view being used as the color well's label. 103 | /// 104 | /// - Parameters: 105 | /// - cgColor: The initial value of the color well's color. 106 | /// - supportsOpacity: A Boolean value that indicates whether 107 | /// the color well allows adjusting the selected color's opacity; 108 | /// the default is true. 109 | /// - label: A view that describes the purpose of the color well. 110 | public init( 111 | cgColor: CGColor, 112 | supportsOpacity: Bool = true, 113 | @ViewBuilder label: () -> Label 114 | ) { 115 | self.init( 116 | configuration: ColorWellConfiguration( 117 | modifiers: [ 118 | .cgColor(cgColor), 119 | .supportsOpacity(supportsOpacity), 120 | .label(label), 121 | ] 122 | ) 123 | ) 124 | } 125 | 126 | /// Creates a color well with an initial color value, with the provided view 127 | /// being used as the color well's label, and the provided action being executed 128 | /// when the color well's color changes. 129 | /// 130 | /// - Parameters: 131 | /// - color: The initial value of the color well's color. 132 | /// - supportsOpacity: A Boolean value that indicates whether 133 | /// the color well allows adjusting the selected color's opacity; 134 | /// the default is true. 135 | /// - label: A view that describes the purpose of the color well. 136 | /// - action: An action to perform when the color well's color changes. 137 | @available(macOS 11.0, *) 138 | public init( 139 | color: Color, 140 | supportsOpacity: Bool = true, 141 | @ViewBuilder label: () -> Label, 142 | action: @escaping (Color) -> Void 143 | ) { 144 | self.init( 145 | configuration: ColorWellConfiguration( 146 | modifiers: [ 147 | .color(color), 148 | .supportsOpacity(supportsOpacity), 149 | .label(label), 150 | .action(action), 151 | ] 152 | ) 153 | ) 154 | } 155 | 156 | /// Creates a color well with an initial color value, with the provided view 157 | /// being used as the color well's label, and the provided action being executed 158 | /// when the color well's color changes. 159 | /// 160 | /// - Note: The color well's color is translated into a `CGColor` from 161 | /// an underlying representation. In some cases, the translation process 162 | /// may be forced to return an approximation, rather than the original 163 | /// color. To receive a color that is guaranteed to be equivalent to the 164 | /// color well's underlying representation, use ``init(color:supportsOpacity:label:action:)``. 165 | /// 166 | /// - Parameters: 167 | /// - cgColor: The initial value of the color well's color. 168 | /// - supportsOpacity: A Boolean value that indicates whether 169 | /// the color well allows adjusting the selected color's opacity; 170 | /// the default is true. 171 | /// - label: A view that describes the purpose of the color well. 172 | /// - action: An action to perform when the color well's color changes. 173 | public init( 174 | cgColor: CGColor, 175 | supportsOpacity: Bool = true, 176 | @ViewBuilder label: () -> Label, 177 | action: @escaping (CGColor) -> Void 178 | ) { 179 | self.init( 180 | configuration: ColorWellConfiguration( 181 | modifiers: [ 182 | .cgColor(cgColor), 183 | .supportsOpacity(supportsOpacity), 184 | .label(label), 185 | .action(action), 186 | ] 187 | ) 188 | ) 189 | } 190 | } 191 | 192 | // MARK: ColorWellView (Label == Never) 193 | @available(macOS 10.15, *) 194 | extension ColorWellView where Label == Never { 195 | /// Creates a color well with an initial color value. 196 | /// 197 | /// - Parameters: 198 | /// - color: The initial value of the color well's color. 199 | /// - supportsOpacity: A Boolean value that indicates whether 200 | /// the color well allows adjusting the selected color's opacity; 201 | /// the default is true. 202 | @available(macOS 11.0, *) 203 | public init( 204 | color: Color, 205 | supportsOpacity: Bool = true 206 | ) { 207 | self.init( 208 | configuration: ColorWellConfiguration( 209 | modifiers: [ 210 | .color(color), 211 | .supportsOpacity(supportsOpacity), 212 | ] 213 | ) 214 | ) 215 | } 216 | 217 | /// Creates a color well with an initial color value. 218 | /// 219 | /// - Parameters: 220 | /// - cgColor: The initial value of the color well's color. 221 | /// - supportsOpacity: A Boolean value that indicates whether 222 | /// the color well allows adjusting the selected color's opacity; 223 | /// the default is true. 224 | public init( 225 | cgColor: CGColor, 226 | supportsOpacity: Bool = true 227 | ) { 228 | self.init( 229 | configuration: ColorWellConfiguration( 230 | modifiers: [ 231 | .cgColor(cgColor), 232 | .supportsOpacity(supportsOpacity), 233 | ] 234 | ) 235 | ) 236 | } 237 | 238 | /// Creates a color well with an initial color value, that executes the 239 | /// given action when its color changes. 240 | /// 241 | /// - Parameters: 242 | /// - color: The initial value of the color well's color. 243 | /// - supportsOpacity: A Boolean value that indicates whether 244 | /// the color well allows adjusting the selected color's opacity; 245 | /// the default is true. 246 | /// - action: An action to perform when the color well's color changes. 247 | @available(macOS 11.0, *) 248 | public init( 249 | color: Color, 250 | supportsOpacity: Bool = true, 251 | action: @escaping (Color) -> Void 252 | ) { 253 | self.init( 254 | configuration: ColorWellConfiguration( 255 | modifiers: [ 256 | .color(color), 257 | .supportsOpacity(supportsOpacity), 258 | .action(action), 259 | ] 260 | ) 261 | ) 262 | } 263 | 264 | /// Creates a color well with an initial color value, that executes the 265 | /// given action when its color changes. 266 | /// 267 | /// - Note: The color well's color is translated into a `CGColor` from 268 | /// an underlying representation. In some cases, the translation process 269 | /// may be forced to return an approximation, rather than the original 270 | /// color. To receive a color that is guaranteed to be equivalent to the 271 | /// color well's underlying representation, use ``init(color:supportsOpacity:action:)``. 272 | /// 273 | /// - Parameters: 274 | /// - cgColor: The initial value of the color well's color. 275 | /// - supportsOpacity: A Boolean value that indicates whether 276 | /// the color well allows adjusting the selected color's opacity; 277 | /// the default is true. 278 | /// - action: An action to perform when the color well's color changes. 279 | public init( 280 | cgColor: CGColor, 281 | supportsOpacity: Bool = true, 282 | action: @escaping (CGColor) -> Void 283 | ) { 284 | self.init( 285 | configuration: ColorWellConfiguration( 286 | modifiers: [ 287 | .cgColor(cgColor), 288 | .supportsOpacity(supportsOpacity), 289 | .action(action), 290 | ] 291 | ) 292 | ) 293 | } 294 | } 295 | 296 | // MARK: ColorWellView (Label == Text) 297 | @available(macOS 10.15, *) 298 | extension ColorWellView where Label == Text { 299 | 300 | // MARK: Generate Label From StringProtocol 301 | 302 | /// Creates a color well with an initial color value, that generates 303 | /// its label from a string. 304 | /// 305 | /// - Parameters: 306 | /// - title: A string that describes the purpose of the color well. 307 | /// - color: The initial value of the color well's color. 308 | /// - supportsOpacity: A Boolean value that indicates whether 309 | /// the color well allows adjusting the selected color's opacity; 310 | /// the default is true. 311 | @available(macOS 11.0, *) 312 | public init( 313 | _ title: S, 314 | color: Color, 315 | supportsOpacity: Bool = true 316 | ) { 317 | self.init( 318 | configuration: ColorWellConfiguration( 319 | modifiers: [ 320 | .title(title), 321 | .color(color), 322 | .supportsOpacity(supportsOpacity), 323 | ] 324 | ) 325 | ) 326 | } 327 | 328 | /// Creates a color well with an initial color value, that generates 329 | /// its label from a string. 330 | /// 331 | /// - Parameters: 332 | /// - title: A string that describes the purpose of the color well. 333 | /// - cgColor: The initial value of the color well's color. 334 | /// - supportsOpacity: A Boolean value that indicates whether 335 | /// the color well allows adjusting the selected color's opacity; 336 | /// the default is true. 337 | public init( 338 | _ title: S, 339 | cgColor: CGColor, 340 | supportsOpacity: Bool = true 341 | ) { 342 | self.init( 343 | configuration: ColorWellConfiguration( 344 | modifiers: [ 345 | .title(title), 346 | .cgColor(cgColor), 347 | .supportsOpacity(supportsOpacity), 348 | ] 349 | ) 350 | ) 351 | } 352 | 353 | /// Creates a color well that generates its label from a string, and 354 | /// performs the given action when its color changes. 355 | /// 356 | /// - Parameters: 357 | /// - title: A string that describes the purpose of the color well. 358 | /// - supportsOpacity: A Boolean value that indicates whether 359 | /// the color well allows adjusting the selected color's opacity; 360 | /// the default is true. 361 | /// - action: An action to perform when the color well's color changes. 362 | public init( 363 | _ title: S, 364 | supportsOpacity: Bool = true, 365 | action: @escaping (Color) -> Void 366 | ) { 367 | self.init( 368 | configuration: ColorWellConfiguration( 369 | modifiers: [ 370 | .title(title), 371 | .supportsOpacity(supportsOpacity), 372 | .action(action), 373 | ] 374 | ) 375 | ) 376 | } 377 | 378 | /// Creates a color well with an initial color value that generates 379 | /// its label from a string, and performs the given action when its 380 | /// color changes. 381 | /// 382 | /// - Parameters: 383 | /// - title: A string that describes the purpose of the color well. 384 | /// - color: The initial value of the color well's color. 385 | /// - supportsOpacity: A Boolean value that indicates whether 386 | /// the color well allows adjusting the selected color's opacity; 387 | /// the default is true. 388 | /// - action: An action to perform when the color well's color changes. 389 | @available(macOS 11.0, *) 390 | public init( 391 | _ title: S, 392 | color: Color, 393 | supportsOpacity: Bool = true, 394 | action: @escaping (Color) -> Void 395 | ) { 396 | self.init( 397 | configuration: ColorWellConfiguration( 398 | modifiers: [ 399 | .title(title), 400 | .color(color), 401 | .supportsOpacity(supportsOpacity), 402 | .action(action), 403 | ] 404 | ) 405 | ) 406 | } 407 | 408 | /// Creates a color well with an initial color value that generates 409 | /// its label from a string, and performs the given action when its 410 | /// color changes. 411 | /// 412 | /// - Note: The color well's color is translated into a `CGColor` from 413 | /// an underlying representation. In some cases, the translation process 414 | /// may be forced to return an approximation, rather than the original 415 | /// color. To receive a color that is guaranteed to be equivalent to the 416 | /// color well's underlying representation, use ``init(_:color:supportsOpacity:action:)-7turx``. 417 | /// 418 | /// - Parameters: 419 | /// - title: A string that describes the purpose of the color well. 420 | /// - cgColor: The initial value of the color well's color. 421 | /// - supportsOpacity: A Boolean value that indicates whether 422 | /// the color well allows adjusting the selected color's opacity; 423 | /// the default is true. 424 | /// - action: An action to perform when the color well's color changes. 425 | public init( 426 | _ title: S, 427 | cgColor: CGColor, 428 | supportsOpacity: Bool = true, 429 | action: @escaping (CGColor) -> Void 430 | ) { 431 | self.init( 432 | configuration: ColorWellConfiguration( 433 | modifiers: [ 434 | .title(title), 435 | .cgColor(cgColor), 436 | .supportsOpacity(supportsOpacity), 437 | .action(action), 438 | ] 439 | ) 440 | ) 441 | } 442 | 443 | // MARK: Generate Label From LocalizedStringKey 444 | 445 | /// Creates a color well with an initial color value, that generates 446 | /// its label from a localized string key. 447 | /// 448 | /// - Parameters: 449 | /// - titleKey: The key for the localized title of the color well. 450 | /// - color: The initial value of the color well's color. 451 | /// - supportsOpacity: A Boolean value that indicates whether 452 | /// the color well allows adjusting the selected color's opacity; 453 | /// the default is true. 454 | @available(macOS 11.0, *) 455 | public init( 456 | _ titleKey: LocalizedStringKey, 457 | color: Color, 458 | supportsOpacity: Bool = true 459 | ) { 460 | self.init( 461 | configuration: ColorWellConfiguration( 462 | modifiers: [ 463 | .titleKey(titleKey), 464 | .color(color), 465 | .supportsOpacity(supportsOpacity), 466 | ] 467 | ) 468 | ) 469 | } 470 | 471 | /// Creates a color well with an initial color value, that generates 472 | /// its label from a localized string key. 473 | /// 474 | /// - Parameters: 475 | /// - titleKey: The key for the localized title of the color well. 476 | /// - cgColor: The initial value of the color well's color. 477 | /// - supportsOpacity: A Boolean value that indicates whether 478 | /// the color well allows adjusting the selected color's opacity; 479 | /// the default is true. 480 | public init( 481 | _ titleKey: LocalizedStringKey, 482 | cgColor: CGColor, 483 | supportsOpacity: Bool = true 484 | ) { 485 | self.init( 486 | configuration: ColorWellConfiguration( 487 | modifiers: [ 488 | .titleKey(titleKey), 489 | .cgColor(cgColor), 490 | .supportsOpacity(supportsOpacity), 491 | ] 492 | ) 493 | ) 494 | } 495 | 496 | /// Creates a color well that generates its label from a localized 497 | /// string key, and performs the given action when its color changes. 498 | /// 499 | /// - Parameters: 500 | /// - titleKey: The key for the localized title of the color well. 501 | /// - supportsOpacity: A Boolean value that indicates whether 502 | /// the color well allows adjusting the selected color's opacity; 503 | /// the default is true. 504 | /// - action: An action to perform when the color well's color changes. 505 | public init( 506 | _ titleKey: LocalizedStringKey, 507 | supportsOpacity: Bool = true, 508 | action: @escaping (Color) -> Void 509 | ) { 510 | self.init( 511 | configuration: ColorWellConfiguration( 512 | modifiers: [ 513 | .titleKey(titleKey), 514 | .supportsOpacity(supportsOpacity), 515 | .action(action), 516 | ] 517 | ) 518 | ) 519 | } 520 | 521 | /// Creates a color well with an initial color value that generates 522 | /// its label from a localized string key, and performs the given action 523 | /// when its color changes. 524 | /// 525 | /// - Parameters: 526 | /// - titleKey: The key for the localized title of the color well. 527 | /// - color: The initial value of the color well's color. 528 | /// - supportsOpacity: A Boolean value that indicates whether 529 | /// the color well allows adjusting the selected color's opacity; 530 | /// the default is true. 531 | /// - action: An action to perform when the color well's color changes. 532 | @available(macOS 11.0, *) 533 | public init( 534 | _ titleKey: LocalizedStringKey, 535 | color: Color, 536 | supportsOpacity: Bool = true, 537 | action: @escaping (Color) -> Void 538 | ) { 539 | self.init( 540 | configuration: ColorWellConfiguration( 541 | modifiers: [ 542 | .titleKey(titleKey), 543 | .color(color), 544 | .supportsOpacity(supportsOpacity), 545 | .action(action), 546 | ] 547 | ) 548 | ) 549 | } 550 | 551 | /// Creates a color well with an initial color value that generates 552 | /// its label from a localized string key, and performs the given action 553 | /// when its color changes. 554 | /// 555 | /// - Note: The color well's color is translated into a `CGColor` from 556 | /// an underlying representation. In some cases, the translation process 557 | /// may be forced to return an approximation, rather than the original 558 | /// color. To receive a color that is guaranteed to be equivalent to the 559 | /// color well's underlying representation, use ``init(_:color:supportsOpacity:action:)-6lguj``. 560 | /// 561 | /// - Parameters: 562 | /// - titleKey: The key for the localized title of the color well. 563 | /// - cgColor: The initial value of the color well's color. 564 | /// - supportsOpacity: A Boolean value that indicates whether 565 | /// the color well allows adjusting the selected color's opacity; 566 | /// the default is true. 567 | /// - action: An action to perform when the color well's color changes. 568 | public init( 569 | _ titleKey: LocalizedStringKey, 570 | cgColor: CGColor, 571 | supportsOpacity: Bool = true, 572 | action: @escaping (CGColor) -> Void 573 | ) { 574 | self.init( 575 | configuration: ColorWellConfiguration( 576 | modifiers: [ 577 | .titleKey(titleKey), 578 | .cgColor(cgColor), 579 | .supportsOpacity(supportsOpacity), 580 | .action(action), 581 | ] 582 | ) 583 | ) 584 | } 585 | } 586 | #endif 587 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/SwiftUI/EnvironmentValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentValues.swift 3 | // ColorWell 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import SwiftUI 8 | 9 | @available(macOS 10.15, *) 10 | private struct ChangeHandlersKey: EnvironmentKey { 11 | static let defaultValue = [(NSColor) -> Void]() 12 | } 13 | 14 | @available(macOS 10.15, *) 15 | private struct ColorWellStyleConfigurationKey: EnvironmentKey { 16 | static let defaultValue = _ColorWellStyleConfiguration() 17 | } 18 | 19 | @available(macOS 10.15, *) 20 | private struct SwatchColorsKey: EnvironmentKey { 21 | static let defaultValue: [NSColor]? = nil 22 | } 23 | 24 | @available(macOS 10.15, *) 25 | extension EnvironmentValues { 26 | /// The change handlers to add to the color wells in this environment. 27 | var changeHandlers: [(NSColor) -> Void] { 28 | get { self[ChangeHandlersKey.self] } 29 | set { self[ChangeHandlersKey.self] = newValue } 30 | } 31 | 32 | /// The style configuration to apply to the color wells in this environment. 33 | var colorWellStyleConfiguration: _ColorWellStyleConfiguration { 34 | get { self[ColorWellStyleConfigurationKey.self] } 35 | set { self[ColorWellStyleConfigurationKey.self] = newValue } 36 | } 37 | 38 | /// The swatch colors to apply to the color wells in this environment. 39 | var swatchColors: [NSColor]? { 40 | get { self[SwatchColorsKey.self] } 41 | set { self[SwatchColorsKey.self] = newValue } 42 | } 43 | } 44 | #endif 45 | -------------------------------------------------------------------------------- /Sources/ColorWell/Views/SwiftUI/ViewModifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModifiers.swift 3 | // ColorWell 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import SwiftUI 8 | 9 | @available(macOS 10.15, *) 10 | extension View { 11 | /// Adds an action to color wells within this view. 12 | /// 13 | /// - Parameter action: An action to perform when a color well's 14 | /// color changes. The closure receives the new color as an input. 15 | public func onColorChange(perform action: @escaping (Color) -> Void) -> some View { 16 | transformEnvironment(\.changeHandlers) { changeHandlers in 17 | changeHandlers.append { color in 18 | action(Color(color)) 19 | } 20 | } 21 | } 22 | 23 | /// Sets the style for color wells within this view. 24 | public func colorWellStyle(_ style: S) -> some View { 25 | transformEnvironment(\.colorWellStyleConfiguration) { configuration in 26 | configuration = style._configuration 27 | } 28 | } 29 | 30 | /// Applies the given swatch colors to the view's color wells. 31 | /// 32 | /// Swatches are user-selectable colors that are shown when 33 | /// a ``ColorWellView`` displays its popover. 34 | /// 35 | /// ![Default swatches](grid-view) 36 | /// 37 | /// Any color well that is part of the current view's hierarchy 38 | /// will update its swatches to the colors provided here. 39 | /// 40 | /// - Parameter colors: The swatch colors to use. 41 | @available(macOS 11.0, *) 42 | public func swatchColors(_ colors: [Color]) -> some View { 43 | transformEnvironment(\.swatchColors) { swatchColors in 44 | swatchColors = colors.map(NSColor.init) 45 | } 46 | } 47 | } 48 | #endif 49 | -------------------------------------------------------------------------------- /Tests/ColorWellTests/ColorWellTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorWellTests.swift 3 | // ColorWell 4 | // 5 | 6 | import XCTest 7 | @testable import ColorWell 8 | 9 | final class ColorWellTests: XCTestCase { 10 | func testCGPointTranslate() { 11 | let tx: CGFloat = 10 12 | let ty: CGFloat = 20 13 | let point1 = CGPoint.zero 14 | let point2 = point1.translating(x: tx, y: ty) 15 | XCTAssertEqual(CGPoint(x: tx, y: ty), point2) 16 | } 17 | 18 | func testCGRectCenter() { 19 | let rect1 = CGRect(x: 0, y: 0, width: 500, height: 500) 20 | let rect2 = CGRect(x: 1000, y: 1000, width: 250, height: 250) 21 | let rect3 = rect2.centered(in: rect1) 22 | XCTAssertEqual(rect3.origin.x, rect1.midX - (rect3.width / 2)) 23 | XCTAssertEqual(rect3.origin.y, rect1.midY - (rect3.height / 2)) 24 | } 25 | 26 | func testCGSizeApplyingInsets() { 27 | let insets = NSEdgeInsets(top: 5, left: 10, bottom: 15, right: 20) 28 | let size1 = CGSize(width: 500, height: 500) 29 | let size2 = size1.applying(insets: insets) 30 | XCTAssertEqual(size2.width, size1.width - insets.horizontal) 31 | XCTAssertEqual(size2.height, size1.height - insets.vertical) 32 | } 33 | 34 | func testComparableClamped() { 35 | var value = 10 36 | value = value.clamped(to: 0...5) 37 | XCTAssertEqual(value, 5) 38 | value = value.clamped(to: 20...100) 39 | XCTAssertEqual(value, 20) 40 | value = value.clamped(to: 10...30) 41 | XCTAssertEqual(value, 20) 42 | } 43 | 44 | func testNSColorFromHexString() throws { 45 | XCTAssertNil( 46 | NSColor(hexString: "green") 47 | ) 48 | XCTAssertNotNil( 49 | NSColor(hexString: "FFFFFF") 50 | ) 51 | try XCTAssertEqual( 52 | XCTUnwrap(NSColor(hexString: "FFFFFF")), 53 | NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) 54 | ) 55 | try XCTAssertEqual( 56 | XCTUnwrap(NSColor(hexString: "000000")), 57 | NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 1) 58 | ) 59 | try XCTAssertEqual( 60 | XCTUnwrap(NSColor(hexString: "FF0000")), 61 | NSColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) 62 | ) 63 | try XCTAssertEqual( 64 | XCTUnwrap(NSColor(hexString: "00FF00")), 65 | NSColor(srgbRed: 0, green: 1, blue: 0, alpha: 1) 66 | ) 67 | try XCTAssertEqual( 68 | XCTUnwrap(NSColor(hexString: "0000FF")), 69 | NSColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) 70 | ) 71 | try XCTAssertTrue( 72 | XCTUnwrap(NSColor(hexString: "0000007F")).resembles( 73 | NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.5), 74 | using: .sRGB, 75 | tolerance: 0.01 76 | ) 77 | ) 78 | try XCTAssertFalse( 79 | XCTUnwrap(NSColor(hexString: "0000007F")).resembles( 80 | NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.4), 81 | using: .sRGB, 82 | tolerance: 0.01 83 | ) 84 | ) 85 | } 86 | 87 | func testNSColorBlendedAndClamped() { 88 | func randomComponent() -> CGFloat { 89 | .random(in: 0...1) 90 | } 91 | 92 | func randomColor() -> NSColor { 93 | NSColor( 94 | red: randomComponent(), 95 | green: randomComponent(), 96 | blue: randomComponent(), 97 | alpha: randomComponent() 98 | ) 99 | } 100 | 101 | for _ in 0..<10_000 { 102 | let color1 = randomColor() 103 | let color2 = randomColor() 104 | let fraction = randomComponent() 105 | 106 | let blended1 = color1.blended(withFraction: fraction, of: color2) 107 | let blended2 = color1.blendedAndClamped(withFraction: fraction, of: color2) 108 | 109 | XCTAssertEqual(blended1, blended2) 110 | } 111 | } 112 | 113 | func testNSEdgeInsets() { 114 | let insets = NSEdgeInsets(top: 20, left: 40, bottom: 60, right: 80) 115 | XCTAssertEqual(insets.horizontal, insets.left + insets.right) 116 | XCTAssertEqual(insets.vertical, insets.top + insets.bottom) 117 | } 118 | } 119 | --------------------------------------------------------------------------------