├── .github └── workflows │ └── compile_check.yml ├── .gitignore ├── LICENSE.txt ├── Package.swift ├── README.md ├── Sources ├── CCyanUtils │ ├── CAKMacrotaskQueue.c │ └── include │ │ ├── CAKMacrotaskQueue.h │ │ └── CCyanUtils.h ├── CyanCombine │ ├── Publisher+AutoCancel.swift │ └── ReplayPublisher.swift ├── CyanConcurrency │ └── Extensions │ │ ├── Collection.swift │ │ └── SequenceAlgorithms.swift ├── CyanExtensions │ ├── AppKit │ │ ├── NSAppearance+Extension.swift │ │ └── NSEdgeInsets.swift │ ├── BundleConfigurationKey.swift │ ├── CGPoint+Extension.swift │ ├── CGRect.swift │ ├── ExtensionNamespace.swift │ ├── Operators.swift │ ├── PlatformColor+Extension.swift │ ├── PlatformEdgeInsets.swift │ ├── PlatformFont+Extension.swift │ ├── String+Extension.swift │ └── UIKit │ │ ├── UIApplication+Extension.swift │ │ ├── UIEdgeInsets.swift │ │ ├── UIScreen+Extension.swift │ │ ├── UIView+Extension.swift │ │ ├── UIViewController+Extension.swift │ │ └── UIWindow+Extension.swift ├── CyanKit │ └── Exports.swift ├── CyanSwiftUI │ ├── AnimatedClickable.swift │ ├── AnyViewModifier.swift │ ├── EditMenu.swift │ ├── Extensions │ │ └── Color.swift │ └── HostingViewReader.swift ├── CyanUI │ ├── BackgroundMaterial.swift │ ├── Banner │ │ ├── BannerManager.swift │ │ ├── PillContainerView.swift │ │ └── PillContainerViewController.swift │ ├── BeautifulButton.swift │ ├── Duotone.swift │ ├── HexView │ │ ├── DataProvider.swift │ │ ├── DrawingHelper.swift │ │ └── HexView.swift │ ├── PopupButton │ │ ├── PopupButton.swift │ │ └── PopupList.swift │ ├── SegmentedControl.swift │ └── VisualEffectView.swift └── CyanUtils │ ├── AnyError.swift │ ├── ArrayBuilder.swift │ ├── ChildProcess.swift │ ├── Defaults.swift │ ├── MacrotaskQueue.swift │ ├── Version.swift │ └── WeakPropertyWrapper.swift └── Tests ├── CyanConcurrencyTests └── ExtensionsTests.swift └── CyanUtilsTests └── ChildProcessTests.swift /.github/workflows/compile_check.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ main, beta ] 4 | pull_request: 5 | branches: [ main, beta ] 6 | 7 | jobs: 8 | build-macos: 9 | runs-on: macos-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - run: swift build -v 13 | build-ios: 14 | runs-on: macos-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - run: swift build -Xswiftc "-sdk" -Xswiftc "`xcrun --sdk iphoneos --show-sdk-path`" -Xswiftc "-target" -Xswiftc "arm64-apple-ios15.0" -v 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CyanKit", 8 | platforms: [.iOS(.v14), .macOS(.v11), .tvOS(.v14)], 9 | products: [ 10 | .library( 11 | name: "CyanKit", 12 | targets: ["CyanKit"]), 13 | .library( 14 | name: "CyanUtils", 15 | targets: ["CyanUtils"]), 16 | ], 17 | targets: [ 18 | .target(name: "CyanExtensions"), 19 | .target(name: "CyanUtils", dependencies: ["CCyanUtils"]), 20 | .target(name: "CyanCombine"), 21 | .target(name: "CyanConcurrency"), 22 | .target(name: "CyanSwiftUI", dependencies: ["CyanExtensions", "CyanUtils"]), 23 | .target(name: "CyanUI", dependencies: ["CyanSwiftUI"]), 24 | .target( 25 | name: "CyanKit", 26 | dependencies: [ 27 | "CyanExtensions", 28 | "CyanUtils", 29 | "CyanCombine", 30 | "CyanConcurrency", 31 | "CyanSwiftUI", 32 | "CyanUI", 33 | ]), 34 | 35 | .target(name: "CCyanUtils"), 36 | 37 | .testTarget( 38 | name: "CyanUtilsTests", 39 | dependencies: ["CyanUtils"]), 40 | .testTarget( 41 | name: "CyanConcurrencyTests", 42 | dependencies: ["CyanConcurrency"]) 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CyanKit 2 | 3 | [![.github/workflows/compile_check.yml](https://github.com/IcyStudio/CyanKit/actions/workflows/compile_check.yml/badge.svg?branch=main)](https://github.com/IcyStudio/CyanKit/actions/workflows/compile_check.yml) 4 | 5 | CyanKit is a cross-platform package that contains something we feel useful for app development. Most components may only be suitable for our private use. 6 | 7 | ## Structure 8 | 9 | The package is splited into a few targets with different usages: 10 | Target | Description 11 | --- | --- 12 | CyanKit | An umbrella module exporting all the other targets. 13 | CyanExtensions | Extensions for existing types in Apple frameworks. 14 | CyanUtils | Miscellaneous components that can be used independently. 15 | CyanUI | Flavored views and controls for SwiftUI. 16 | 17 | ## Getting Started 18 | CyanKit heavily uses [SwiftPM](https://swift.org/package-manager/) as its build tool, so we recommend using that as well. If you want to depend on CyanKit in your own project, it's as simple as adding a `dependencies` clause to your `Package.swift`: 19 | 20 | ```swift 21 | dependencies: [ 22 | .package(url: "https://github.com/IcyStudio/CyanKit.git", from: "4.0.0") 23 | ] 24 | ``` 25 | 26 | and then adding the appropriate CyanKit module(s) to your target dependencies. 27 | -------------------------------------------------------------------------------- /Sources/CCyanUtils/CAKMacrotaskQueue.c: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/10/20. 3 | // Copyright (c) 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #include "CAKMacrotaskQueue.h" 12 | 13 | typedef struct _CAKMacrotask { 14 | void *info; 15 | void (^block)(void); 16 | struct _CAKMacrotask *nextTask; 17 | } CAKMacrotask; 18 | 19 | typedef struct _CAKMacrotaskQueue { 20 | CAKMacrotask *headTask; 21 | CAKMacrotask *tailTask; 22 | CFRunLoopSourceRef source; 23 | CFRunLoopRef rl; 24 | os_unfair_lock lock; 25 | } CAKMacrotaskQueue; 26 | 27 | static __inline__ 28 | CAKMacrotask *CAKMacrotaskCreateWithHandler(void (^block)(void)) { 29 | CAKMacrotask *task = malloc(sizeof(CAKMacrotask)); 30 | task->info = NULL; 31 | task->block = Block_copy(block); 32 | task->nextTask = NULL; 33 | return task; 34 | } 35 | 36 | static __inline__ void CAKMacrotaskFree(CAKMacrotask *task) { 37 | Block_release(task->block); 38 | free(task); 39 | } 40 | 41 | static void __attribute__((noinline)) 42 | __CAKMACROTASKQUEUE_IS_CALLING_OUT_TO_A_TASK_BLOCK__(CAKMacrotask *task) { 43 | task->block(); 44 | } 45 | 46 | static CAKMacrotaskQueue *sQueue = NULL; 47 | 48 | static void __CAKMacrotaskQueuePokeRunLoop(CAKMacrotaskQueue *queue); 49 | 50 | static CAKMacrotask *__CAKMacrotaskQueuePopTask(CAKMacrotaskQueue *queue) { 51 | os_unfair_lock_lock(&queue->lock); 52 | CAKMacrotask *task = queue->headTask; 53 | if (__builtin_expect(!task, false)) { // cold-path 54 | os_unfair_lock_unlock(&queue->lock); 55 | return NULL; 56 | } 57 | CAKMacrotask *nextTask = task->nextTask; 58 | queue->headTask = nextTask; 59 | if (__builtin_expect(!nextTask, false)) { // cold-path 60 | queue->tailTask = NULL; 61 | } 62 | os_unfair_lock_unlock(&queue->lock); 63 | 64 | return task; 65 | } 66 | 67 | #define RUNLOOP_DEADLINE 0.016 68 | 69 | static void __CAKMacrotaskQueueDrainUntilDeadline(CAKMacrotaskQueue *queue) { 70 | CFAbsoluteTime startTime = CFAbsoluteTimeGetCurrent(); 71 | while ((CFAbsoluteTimeGetCurrent() - startTime) < RUNLOOP_DEADLINE) { 72 | CAKMacrotask *task = __CAKMacrotaskQueuePopTask(queue); 73 | if (__builtin_expect(!task, false)) { 74 | return; 75 | } 76 | __CAKMACROTASKQUEUE_IS_CALLING_OUT_TO_A_TASK_BLOCK__(task); 77 | CAKMacrotaskFree(task); 78 | } 79 | 80 | // We may still have remaining tasks to execute, signal the runloop 81 | // for the next loop. 82 | __CAKMacrotaskQueuePokeRunLoop(queue); 83 | } 84 | 85 | static void __CAKMacrotaskRunloopObserverCallback(CFRunLoopObserverRef observer, 86 | CFRunLoopActivity activity, 87 | void *info) { 88 | CAKMacrotaskQueue *queue = (CAKMacrotaskQueue *) info; 89 | __CAKMacrotaskQueueDrainUntilDeadline(queue); 90 | } 91 | 92 | static void __CAKMacrotaskQueueInitMain() { 93 | CAKMacrotaskQueue *queue = malloc(sizeof(CAKMacrotaskQueue)); 94 | queue->headTask = NULL; 95 | queue->tailTask = NULL; 96 | queue->lock = OS_UNFAIR_LOCK_INIT; 97 | 98 | CFRunLoopRef rl = CFRunLoopGetMain(); 99 | queue->rl = rl; 100 | 101 | CFRunLoopSourceContext ctx; 102 | ctx.version = 0; 103 | ctx.info = NULL; 104 | ctx.retain = NULL; 105 | ctx.release = NULL; 106 | ctx.copyDescription = NULL; 107 | ctx.equal = NULL; 108 | ctx.hash = NULL; 109 | ctx.schedule = NULL; 110 | ctx.cancel = NULL; 111 | ctx.perform = NULL; 112 | CFRunLoopSourceRef source = CFRunLoopSourceCreate(NULL, 0, &ctx); 113 | queue->source = source; 114 | CFRunLoopAddSource(rl, source, kCFRunLoopCommonModes); 115 | 116 | CFRunLoopObserverContext obCtx; 117 | obCtx.version = 0; 118 | obCtx.info = queue; 119 | obCtx.retain = NULL; 120 | obCtx.release = NULL; 121 | obCtx.copyDescription = NULL; 122 | CFRunLoopObserverRef observer = 123 | CFRunLoopObserverCreate(NULL, kCFRunLoopBeforeWaiting, true, 0, 124 | __CAKMacrotaskRunloopObserverCallback, &obCtx); 125 | CFRunLoopAddObserver(rl, observer, kCFRunLoopCommonModes); 126 | 127 | sQueue = queue; 128 | } 129 | 130 | static void __CAKMacrotaskQueuePokeRunLoop(CAKMacrotaskQueue *queue) { 131 | CFRunLoopSourceSignal(queue->source); 132 | CFRunLoopWakeUp(queue->rl); 133 | } 134 | 135 | CAKMacrotaskQueue *CAKMacrotaskQueueGetMain() { 136 | if (__builtin_expect(sQueue != NULL, true)) { 137 | return sQueue; 138 | } 139 | 140 | if (pthread_main_np()) { 141 | __CAKMacrotaskQueueInitMain(); 142 | } else { 143 | dispatch_sync(dispatch_get_main_queue(), ^{ 144 | __CAKMacrotaskQueueInitMain(); 145 | }); 146 | } 147 | 148 | return sQueue; 149 | } 150 | 151 | void CAKMacrotaskQueueAddTaskWithHandler(CAKMacrotaskQueue *queue, void (^block)(void)) { 152 | CAKMacrotask *task = CAKMacrotaskCreateWithHandler(block); 153 | 154 | os_unfair_lock_lock(&queue->lock); 155 | if (__builtin_expect(!queue->headTask, false)) { 156 | // The queue is empty, enqueue the first task. 157 | queue->headTask = task; 158 | queue->tailTask = task; 159 | } else { 160 | queue->tailTask->nextTask = task; 161 | queue->tailTask = task; 162 | } 163 | os_unfair_lock_unlock(&queue->lock); 164 | 165 | __CAKMacrotaskQueuePokeRunLoop(queue); 166 | } 167 | -------------------------------------------------------------------------------- /Sources/CCyanUtils/include/CAKMacrotaskQueue.h: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/10/20. 3 | // Copyright (c) 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | #ifndef CAKMacrotaskQueue_h 7 | #define CAKMacrotaskQueue_h 8 | 9 | typedef struct _CAKMacrotaskQueue CAKMacrotaskQueue; 10 | 11 | extern CAKMacrotaskQueue *CAKMacrotaskQueueGetMain(void); 12 | 13 | extern void CAKMacrotaskQueueAddTaskWithHandler(CAKMacrotaskQueue *queue, void (^block)(void)); 14 | 15 | #endif /* CAKMacrotaskQueue_h */ 16 | -------------------------------------------------------------------------------- /Sources/CCyanUtils/include/CCyanUtils.h: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/10/20. 3 | // Copyright (c) 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | #ifndef CCyanUtils_h 7 | #define CCyanUtils_h 8 | 9 | #include "CAKMacrotaskQueue.h" 10 | 11 | #endif /* CCyanUtils_h */ 12 | -------------------------------------------------------------------------------- /Sources/CyanCombine/Publisher+AutoCancel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/1/8. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | import Combine 7 | 8 | fileprivate class _CancellableHolder: Cancellable { 9 | 10 | var cancellable: AnyCancellable? 11 | 12 | func cancel() { 13 | cancellable = nil 14 | } 15 | 16 | } 17 | 18 | public extension Publisher { 19 | 20 | var cyan: PublisherExtensionNamespace { 21 | return .init(extendedObject: self) 22 | } 23 | 24 | } 25 | 26 | public struct PublisherExtensionNamespace where T: Publisher { 27 | 28 | fileprivate let extendedObject: T 29 | 30 | @discardableResult public func sinkOnce( 31 | receiveCompletion: @escaping ((Subscribers.Completion) -> Void), 32 | receiveValue: @escaping ((T.Output) -> Void) 33 | ) -> Cancellable { 34 | let cancellableHolder = _CancellableHolder() 35 | cancellableHolder.cancellable = extendedObject.sink { 36 | receiveCompletion($0) 37 | cancellableHolder.cancel() 38 | } receiveValue: { 39 | receiveValue($0) 40 | cancellableHolder.cancel() 41 | } 42 | return cancellableHolder 43 | } 44 | 45 | @discardableResult public func sinkOnce(receiveValue: @escaping ((T.Output) -> Void)) -> Cancellable { 46 | return sinkOnce { _ in } receiveValue: { 47 | receiveValue($0) 48 | } 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /Sources/CyanCombine/ReplayPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2021/12/5. 3 | // Copyright (c) 2021 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import Combine 8 | 9 | fileprivate class _ReplayPublisherBuffer where E: Error { 10 | 11 | let upstreamPublisher: AnyPublisher 12 | let lock = NSLock() 13 | var buffer = [O]() 14 | var cancellable: AnyCancellable? 15 | 16 | init(_ upstreamPublisher: AnyPublisher) { 17 | self.upstreamPublisher = upstreamPublisher 18 | 19 | cancellable = upstreamPublisher.sink { _ in } receiveValue: { [weak self] item in 20 | self?.withBuffer { items in 21 | items.append(item) 22 | } 23 | } 24 | } 25 | 26 | func withBuffer(_ action: (inout [O]) -> ()) { 27 | lock.lock() 28 | action(&buffer) 29 | lock.unlock() 30 | } 31 | 32 | } 33 | 34 | fileprivate class _ReplayPublisherSubscription: Subscription where E: Error { 35 | 36 | var cancellable: AnyCancellable? = nil 37 | 38 | init(subscriber: S, buffer: _ReplayPublisherBuffer) 39 | where S: Subscriber, S.Input == O, S.Failure == E 40 | { 41 | subscriber.receive(subscription: self) 42 | buffer.withBuffer { items in 43 | for item in items { 44 | let _ = subscriber.receive(item) 45 | } 46 | let cancellable = buffer.upstreamPublisher.sink { _ in } receiveValue: { item in 47 | let _ = subscriber.receive(item) 48 | } 49 | self.cancellable = cancellable 50 | } 51 | } 52 | 53 | func request(_ demand: Subscribers.Demand) { } 54 | 55 | func cancel() { } 56 | 57 | } 58 | 59 | struct ReplayPublisher: Publisher where E: Error { 60 | 61 | typealias Output = O 62 | typealias Failure = E 63 | 64 | private let buffer: _ReplayPublisherBuffer 65 | 66 | init

(_ upstreamPublisher: P) where P: Publisher, P.Output == O, P.Failure == E { 67 | self.buffer = .init(upstreamPublisher.eraseToAnyPublisher()) 68 | } 69 | 70 | func receive(subscriber: S) where S : Subscriber, E == S.Failure, O == S.Input { 71 | let _ = _ReplayPublisherSubscription(subscriber: subscriber, buffer: buffer) 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /Sources/CyanConcurrency/Extensions/Collection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/6/2. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension Collection { 9 | 10 | /// Asynchronously creates an array containing the results of mapping the given closure 11 | /// over the sequence's elements. 12 | /// 13 | /// - Parameter transform: An asynchronous mapping closure. `transform` accepts an 14 | /// element of this sequence as its parameter and returns a transformed 15 | /// value of the same or of a different type. 16 | /// 17 | /// - Returns: An array containing the transformed elements of this 18 | /// sequence. 19 | @inlinable 20 | public func mapAsync(_ transform: @escaping (Element) async throws -> T) async rethrows -> [T] { 21 | let n = self.count 22 | if n == 0 { 23 | return [] 24 | } 25 | 26 | return try await withThrowingTaskGroup(of: (Int, T).self, returning: [T].self) { group in 27 | for (index, elem) in self.enumerated() { 28 | group.addTask { 29 | return (index, try await transform(elem)) 30 | } 31 | } 32 | 33 | var sparseArray = [Int : T](minimumCapacity: n) 34 | 35 | for try await (index, resultElem) in group { 36 | sparseArray[index] = resultElem 37 | } 38 | 39 | return sparseArray 40 | .sorted { lhs, rhs in lhs.key < rhs.key } 41 | .map { $0.value } 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /Sources/CyanConcurrency/Extensions/SequenceAlgorithms.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/5/31. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension Sequence { 9 | 10 | /// Asynchronously creates an array containing the results of mapping the given closure 11 | /// over the sequence's elements. 12 | /// 13 | /// - Parameter transform: An asynchronous mapping closure. `transform` accepts an 14 | /// element of this sequence as its parameter and returns a transformed 15 | /// value of the same or of a different type. 16 | /// 17 | /// - Returns: An array containing the transformed elements of this 18 | /// sequence. 19 | @inlinable 20 | public func map(_ transform: (Self.Element) async throws -> T) async rethrows -> [T] { 21 | let initialCapacity = underestimatedCount 22 | var result = ContiguousArray() 23 | result.reserveCapacity(initialCapacity) 24 | 25 | var iterator = self.makeIterator() 26 | 27 | // Add elements up to the initial capacity without checking for regrowth. 28 | for _ in 0.. Bool) async rethrows -> [Element] { 52 | var result = ContiguousArray() 53 | var iterator = self.makeIterator() 54 | while let element = iterator.next() { 55 | if try await isIncluded(element) { 56 | result.append(element) 57 | } 58 | } 59 | return Array(result) 60 | } 61 | 62 | } 63 | 64 | extension Sequence { 65 | 66 | /// Calls the given asynchronous closure on each element in the sequence in the same order 67 | /// as a `for`-`in` loop. 68 | /// 69 | /// Using the `forEach` method is distinct from a `for`-`in` loop in two 70 | /// important ways: 71 | /// 72 | /// 1. You cannot use a `break` or `continue` statement to exit the current 73 | /// call of the `body` closure or skip subsequent calls. 74 | /// 2. Using the `return` statement in the `body` closure will exit only from 75 | /// the current call to `body`, not from any outer scope, and won't skip 76 | /// subsequent calls. 77 | /// 78 | /// - Parameter body: An asynchronous closure that takes an element of the sequence as a 79 | /// parameter. 80 | @inlinable 81 | public func forEach(_ body: (Element) async throws -> Void) async rethrows { 82 | for element in self { 83 | try await body(element) 84 | } 85 | } 86 | 87 | } 88 | 89 | extension Sequence { 90 | 91 | /// Asynchronously creates an array containing the non-`nil` results of calling the given 92 | /// transformation with each element of this sequence. 93 | /// 94 | /// Use this method to receive an array of non-optional values when your 95 | /// transformation produces an optional value. 96 | /// 97 | /// - Parameter transform: An asynchronous closure that accepts an element of this 98 | /// sequence as its argument and returns an optional value. 99 | /// - Returns: An array of the non-`nil` results of calling `transform` 100 | /// with each element of the sequence. 101 | @inlinable 102 | public func compactMap(_ transform: (Element) async throws -> ElementOfResult?) async rethrows -> [ElementOfResult] { 103 | var result: [ElementOfResult] = [] 104 | for element in self { 105 | if let newElement = try await transform(element) { 106 | result.append(newElement) 107 | } 108 | } 109 | return result 110 | } 111 | 112 | } 113 | 114 | extension Sequence { 115 | 116 | /// Asynchronously returns the result of combining the elements of the sequence using the 117 | /// given closure. 118 | /// 119 | /// Use the `reduce(_:_:)` method to produce a single value from the elements 120 | /// of an entire sequence. 121 | /// 122 | /// The asynchronous `nextPartialResult` closure is called sequentially with an 123 | /// accumulating value initialized to `initialResult` and each element of 124 | /// the sequence. 125 | /// 126 | /// - Parameters: 127 | /// - initialResult: The value to use as the initial accumulating value. 128 | /// `initialResult` is passed to `nextPartialResult` the first time the 129 | /// closure is executed. 130 | /// - nextPartialResult: An asynchronous closure that combines an accumulating value and 131 | /// an element of the sequence into a new accumulating value, to be used 132 | /// in the next call of the `nextPartialResult` closure or returned to 133 | /// the caller. 134 | /// 135 | /// - Returns: The final accumulated value. If the sequence has no elements, 136 | /// the result is `initialResult`. 137 | @inlinable 138 | public func reduce(_ initialResult: Result, _ nextPartialResult: (_ partialResult: Result, Element) async throws -> Result) async rethrows -> Result { 139 | var accumulator = initialResult 140 | for element in self { 141 | accumulator = try await nextPartialResult(accumulator, element) 142 | } 143 | return accumulator 144 | } 145 | 146 | /// Asynchronously returns the result of combining the elements of the sequence using the 147 | /// given closure. 148 | /// 149 | /// Use the `reduce(into:_:)` method to produce a single value from the 150 | /// elements of an entire sequence. 151 | /// 152 | /// This method is preferred over `reduce(_:_:)` for efficiency when the 153 | /// result is a copy-on-write type, for example an Array or a Dictionary. 154 | /// 155 | /// The asynchronous `updateAccumulatingResult` closure is called sequentially with a 156 | /// mutable accumulating value initialized to `initialResult` and each element 157 | /// of the sequence. 158 | /// 159 | /// If the sequence has no elements, `updateAccumulatingResult` is never 160 | /// executed and `initialResult` is the result of the call to 161 | /// `reduce(into:_:)`. 162 | /// 163 | /// - Parameters: 164 | /// - initialResult: The value to use as the initial accumulating value. 165 | /// - updateAccumulatingResult: An asynchronous closure that updates the accumulating 166 | /// value with an element of the sequence. 167 | /// 168 | /// - Returns: The final accumulated value. If the sequence has no elements, 169 | /// the result is `initialResult`. 170 | @inlinable 171 | public func reduce( 172 | into initialResult: __owned Result, 173 | _ updateAccumulatingResult: (_ partialResult: inout Result, Element) async throws -> () 174 | ) async rethrows -> Result { 175 | var accumulator = initialResult 176 | for element in self { 177 | try await updateAccumulatingResult(&accumulator, element) 178 | } 179 | return accumulator 180 | } 181 | 182 | } 183 | 184 | extension Sequence { 185 | 186 | /// Asynchronously creates an array containing the concatenated results of calling the 187 | /// given transformation with each element of this sequence. 188 | /// 189 | /// Use this method to receive a single-level collection when your 190 | /// transformation produces a sequence or collection for each element. 191 | /// 192 | /// In fact, `s.flatMap(transform)` is equivalent to 193 | /// `Array(s.map(transform).joined())`. 194 | /// 195 | /// - Parameter transform: An asynchronous closure that accepts an element of this 196 | /// sequence as its argument and returns a sequence or collection. 197 | /// 198 | /// - Returns: The resulting flattened array. 199 | @inlinable 200 | public func flatMap(_ transform: (Element) async throws -> SegmentOfResult) async rethrows -> [SegmentOfResult.Element] { 201 | var result: [SegmentOfResult.Element] = [] 202 | for element in self { 203 | result.append(contentsOf: try await transform(element)) 204 | } 205 | return result 206 | } 207 | 208 | } 209 | 210 | extension Sequence { 211 | 212 | /// Asynchronously returns a Boolean value indicating whether the sequence contains an 213 | /// element that satisfies the given predicate. 214 | /// 215 | /// You can use the predicate to check for an element of a type that 216 | /// doesn't conform to the `Equatable` protocol. 217 | /// 218 | /// Alternatively, a predicate can be satisfied by a range of `Equatable` 219 | /// elements or a general condition. 220 | /// 221 | /// - Parameter predicate: An asynchronous closure that takes an element of the sequence 222 | /// as its argument and returns a Boolean value that indicates whether 223 | /// the passed element represents a match. 224 | /// 225 | /// - Returns: `true` if the sequence contains an element that satisfies 226 | /// `predicate`; otherwise, `false`. 227 | @inlinable 228 | public func contains(where predicate: (Element) async throws -> Bool) async rethrows -> Bool { 229 | for e in self { 230 | if try await predicate(e) { 231 | return true 232 | } 233 | } 234 | return false 235 | } 236 | 237 | /// Asynchronously returns a Boolean value indicating whether every element of a sequence 238 | /// satisfies a given predicate. 239 | /// 240 | /// If the sequence is empty, this method returns `true`. 241 | /// 242 | /// - Parameter predicate: An asynchronous closure that takes an element of the sequence 243 | /// as its argument and returns a Boolean value that indicates whether 244 | /// the passed element satisfies a condition. 245 | /// 246 | /// - Returns: `true` if the sequence contains only elements that satisfy 247 | /// `predicate`; otherwise, `false`. 248 | @inlinable 249 | public func allSatisfy(_ predicate: (Element) async throws -> Bool) async rethrows -> Bool { 250 | return try await !contains { try await !predicate($0) } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/AppKit/NSAppearance+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/3/31. 3 | // Copyright (c) 2021 Cyandev. All rights reserved. 4 | // 5 | 6 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 7 | 8 | import AppKit 9 | 10 | public enum UserInterfaceStyle { 11 | case light 12 | case dark 13 | } 14 | 15 | public extension NSAppearance { 16 | 17 | var userInterfaceStyle: UserInterfaceStyle { 18 | if name == Name.darkAqua || 19 | name == Name.vibrantDark || 20 | name == Name.accessibilityHighContrastDarkAqua || 21 | name == Name.accessibilityHighContrastVibrantDark { 22 | return .dark 23 | } 24 | return .light 25 | } 26 | 27 | } 28 | 29 | #endif 30 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/AppKit/NSEdgeInsets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2023/8/13. 3 | // Copyright (c) 2023 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(macOS) 7 | import Foundation 8 | 9 | public typealias PlatformEdgeInsets = NSEdgeInsets 10 | 11 | public extension NSEdgeInsets { 12 | static let zero: NSEdgeInsets = NSEdgeInsetsZero 13 | } 14 | #endif 15 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/BundleConfigurationKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/10/25. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | /// A structure that defines the property list key of bundle configuration. 9 | public struct BundleConfigurationKey { 10 | 11 | /// The type of bundle. 12 | /// 13 | /// This key consists of a four-letter code for the bundle type. 14 | /// For apps, the code is `APPL`, for frameworks, it's `FMWK`, and for bundles, it's `BNDL`. 15 | /// The default value is derived from the bundle extension or, if it can't be derived, the default value is `BNDL`. 16 | public static let cfBundlePackageType: String = "CFBundlePackageType" 17 | 18 | #if os(macOS) 19 | /// The category that best describes your app for the App Store. 20 | public static let lsApplicationCategoryType: String = "LSApplicationCategoryType" 21 | #endif 22 | 23 | /// A unique identifier for a bundle. 24 | /// 25 | /// A *bundle ID* uniquely identifies a single app throughout the system. 26 | /// The bundle ID string must contain only alphanumeric characters (A–Z, a–z, and 0–9), hyphens (-), and periods (.). 27 | /// Typically, you use a reverse-DNS format for bundle ID strings. 28 | /// Bundle IDs are case-insensitive. 29 | /// 30 | /// The operating system uses the bundle ID to identify the app when applying specified preferences. 31 | /// Similarly, Launch Services uses the bundle ID to locate an app capable of opening a particular file. 32 | /// The bundle ID also validates an app's signature. 33 | /// 34 | /// > Important: 35 | /// > The bundle ID in the information property list must match the bundle ID you enter in App Store Connect. 36 | /// > After you upload a build to App Store Connect, you can't change the bundle ID or delete the associated explicit App ID in your developer account. 37 | public static let cfBundleIdentifier: String = "CFBundleIdentifier" 38 | 39 | #if os(watchOS) 40 | /// The bundle ID of the watchOS app. 41 | /// 42 | /// This key is automatically included in your WatchKit extension's information property list when you create a watchOS project from a template. 43 | public static let wkAppBundleIdentifier: String = "WKAppBundleIdentifier" 44 | 45 | /// The bundle ID of the watchOS app's companion iOS app. 46 | /// 47 | /// Xcode automatically includes this key in the WatchKit app's information property list when you create a watchOS project from a template. 48 | /// The value should be the same as the iOS app's `CFBundleIdentifier`. 49 | public static let wkCompanionAppBundleIdentifier: String = "WKCompanionAppBundleIdentifier" 50 | #endif 51 | 52 | /// A user-visible short name for the bundle. 53 | /// 54 | /// This name can contain up to 15 characters. 55 | /// The system may display it to users if `CFBundleDisplayName` isn't set. 56 | public static let cfBundleName: String = "CFBundleName" 57 | 58 | /// The user-visible name for the bundle, used by Siri and visible on the iOS Home screen. 59 | /// 60 | /// Use this key if you want a product name that's longer than `CFBundleName`. 61 | public static let cfBundleDisplayName: String = "CFBundleDisplayName" 62 | 63 | /// A replacement for the app name in text-to-speech operations. 64 | public static let cfBundleSpokenName: String = "CFBundleSpokenName" 65 | 66 | /// The version of the build that identifies an iteration of the bundle. 67 | /// 68 | /// This key is a machine-readable string composed of one to three period-separated integers, such as 10.14.1. 69 | /// The string can only contain numeric characters (0-9) and periods. 70 | /// 71 | /// Each integer provides information about the build version in the format [*Major*].[*Minor*].[*Patch*]: 72 | /// - Major: A major revision number. 73 | /// - Minor: A minor revision number. 74 | /// - Patch: A maintenance release number. 75 | /// 76 | /// You can include more integers but the system ignores them. 77 | /// 78 | /// You can also abbreviate the build version by using only one or two integers, where missing integers in the format are interpreted as zeros. 79 | /// For example, 0 specifies 0.0.0, 10 specifies 10.0.0, and 10.5 specifies 10.5.0. 80 | /// 81 | /// This key is required by the App Store and is used throughout the system to identify the version of the build. 82 | /// For macOS apps, increment the build version before you distribute a build. 83 | public static let cfBundleVersion: String = "CFBundleVersion" 84 | 85 | /// The release or version number of the bundle. 86 | /// 87 | /// This key is a user-visible string for the version of the bundle. 88 | /// The required format is three period-separated integers, such as 10.14.1. 89 | /// The string can only contain numeric characters (0-9) and periods. 90 | /// 91 | /// Each integer provides information about the build version in the format [*Major*].[*Minor*].[*Patch*]: 92 | /// - Major: A major revision number. 93 | /// - Minor: A minor revision number. 94 | /// - Patch: A maintenance release number. 95 | /// 96 | /// This key is used throughout the system to identify the version of the bundle. 97 | public static let cfBundleShortVersionString: String = "CFBundleShortVersionString" 98 | 99 | /// The current version of the Information Property List structure. 100 | /// 101 | /// Xcode adds this key automatically. Don't change the value. 102 | public static let cfBundleInfoDictionaryVersion: String = "CFBundleInfoDictionaryVersion" 103 | 104 | #if os(macOS) 105 | /// A human-readable copyright notice for the bundle. 106 | public static let nsHumanReadableCopyright: String = "NSHumanReadableCopyright" 107 | #endif 108 | 109 | #if os(macOS) || targetEnvironment(macCatalyst) 110 | /// The minimum version of the operating system required for the app to run in macOS. 111 | /// 112 | /// Use this key to indicate the minimum macOS release that your app supports. 113 | /// The App Store uses this key to indicate the macOS releases on which your app can run, and to show compatibility with a person's Mac. 114 | /// 115 | /// Starting with macOS 11.4, the lowest version number you can specify as the value for the `LSMinimumSystemVersion` key is: 116 | /// - `10` if your app links against the macOS SDK. 117 | /// - `10.15` if your app links against the iOS 14.3 SDK (or later) and builds using Mac Catalyst. 118 | /// - `11` if your iPad or iPhone app links against the iOS 14.3 SDK (or later) and can run on a Mac with Apple silicon. 119 | /// 120 | /// To specify the minimum version of iOS, iPadOS, tvOS, or watchOS that your app supports, use `MinimumOSVersion`. 121 | public static let lsMinimumSystemVersion: String = "LSMinimumSystemVersion" 122 | #endif 123 | 124 | #if os(macOS) 125 | /// The minimum version of macOS required for the app to run on a set of architectures. 126 | public static let lsMinimumSystemVersionByArchitecture: String = "LSMinimumSystemVersionByArchitecture" 127 | #else 128 | /// The minimum version of the operating system required for the app to run in iOS, iPadOS, tvOS, and watchOS. 129 | /// 130 | /// The App Store uses this key to indicate the OS releases on which your app can run. 131 | /// 132 | /// Don't specify `MinimumOSVersion` in the `Info.plist` file for apps built in Xcode. It uses the value of the Deployment Target in the General settings pane. 133 | /// 134 | /// For macOS, see `LSMinimumSystemVersion`. 135 | public static let minimumOSVersion: String = "MinimumOSVersion" 136 | #endif 137 | 138 | #if os(iOS) 139 | /// A Boolean value indicating whether the app must run in iOS. 140 | public static let lsRequiresIPhoneOS: String = "LSRequiresIPhoneOS" 141 | #endif 142 | 143 | #if os(watchOS) 144 | /// A Boolean value that indicates whether the bundle is a watchOS app. 145 | /// 146 | /// Xcode automatically includes this key in the WatchKit app's information property list when you create a watchOS project from a template. 147 | public static let wkWatchKitApp: String = "WKWatchKitApp" 148 | #endif 149 | 150 | /// The default language and region for the bundle, as a language ID. 151 | /// 152 | /// The system uses this key as the language if it can't locate a resource for the user's preferred language. 153 | /// The value should be a *language ID* that identifies a language, dialect, or script. 154 | public static let cfBundleDevelopmentRegion: String = "CFBundleDevelopmentRegion" 155 | 156 | /// The localizations handled manually by your app. 157 | public static let cfBundleLocalizations: String = "CFBundleLocalizations" 158 | 159 | /// A Boolean value that indicates whether the bundle supports the retrieval of localized strings from frameworks. 160 | public static let cfBundleAllowMixedLocalizations: String = "CFBundleAllowMixedLocalizations" 161 | 162 | #if os(macOS) 163 | /// A Boolean value that enables the Caps Lock key to switch between Latin and non-Latin input sources. 164 | /// 165 | /// Latin input sources, such as ABC, U.S., and Vietnamese, output characters in Latin script. 166 | /// Non-Latin input sources, such as Bulgarian (Cyrillic script), Hindi (Devanagari script), and Urdu (Arabic script), output characters in scripts other than Latin. 167 | /// 168 | /// After implementing the key, users can enable or disable this functionality by modifying the "Use Caps Lock to switch to and from" preference, 169 | /// which can be found in System Preferences > Keyboard > Input Sources. 170 | public static let latinInputCapsLockLanguageSwitchCapable: String = "TICapsLockLanguageSwitchCapable" 171 | 172 | /// The name of the bundle's HTML help file. 173 | public static let cfAppleHelpAnchor: String = "CFAppleHelpAnchor" 174 | 175 | /// The name of the help file that will be opened in Help Viewer. 176 | public static let cfBundleHelpBookName: String = "CFBundleHelpBookName" 177 | 178 | /// The name of the folder containing the bundle's help files. 179 | public static let cfBundleHelpBookFolder: String = "CFBundleHelpBookFolder" 180 | #endif 181 | 182 | } 183 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/CGPoint+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/1/18. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import CoreGraphics 8 | 9 | public extension CGPoint { 10 | 11 | func nearBy(_ point: CGPoint, tolerance: CGFloat = 2) -> Bool { 12 | abs(x - point.x) <= tolerance && abs(y - point.y) <= tolerance 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/CGRect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2023/8/13. 3 | // Copyright (c) 2023 ktiays. All rights reserved. 4 | // 5 | 6 | import CoreGraphics 7 | import Foundation 8 | 9 | extension CGRect { 10 | 11 | #if os(macOS) 12 | public func inset(by insets: NSEdgeInsets) -> Self { 13 | .init( 14 | x: origin.x + insets.left, 15 | y: origin.y + insets.top, 16 | width: width - insets.left - insets.right, 17 | height: height - insets.top - insets.bottom 18 | ) 19 | } 20 | #endif 21 | 22 | public func inflate(by insets: PlatformEdgeInsets) -> Self { 23 | inset(by: insets.inverted) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/ExtensionNamespace.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2021/6/21. 3 | // Copyright (c) 2021 Cyandev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public struct ExtensionNamespace { 9 | let extendedObject: Object 10 | } 11 | 12 | public protocol CyanExtending { } 13 | 14 | public extension CyanExtending { 15 | 16 | var cyan: ExtensionNamespace { 17 | return .init(extendedObject: self) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/Operators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/1/8. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import CoreGraphics 8 | 9 | infix operator |> : MultiplicationPrecedence 10 | 11 | @discardableResult 12 | public func |>(_ lhs: T, _ rhs: (T) -> U) -> U { 13 | rhs(lhs) 14 | } 15 | 16 | // MARK: - Int & CGFloat 17 | 18 | public func + (_ lhs: Int, _ rhs: CGFloat) -> CGFloat { 19 | CGFloat(lhs) + rhs 20 | } 21 | 22 | public func + (_ lhs: CGFloat, _ rhs: Int) -> CGFloat { 23 | lhs + CGFloat(rhs) 24 | } 25 | 26 | public func - (_ lhs: Int, _ rhs: CGFloat) -> CGFloat { 27 | CGFloat(lhs) - rhs 28 | } 29 | 30 | public func - (_ lhs: CGFloat, _ rhs: Int) -> CGFloat { 31 | lhs - CGFloat(rhs) 32 | } 33 | 34 | public func * (_ lhs: Int, _ rhs: CGFloat) -> CGFloat { 35 | CGFloat(lhs) * rhs 36 | } 37 | 38 | public func * (_ lhs: CGFloat, _ rhs: Int) -> CGFloat { 39 | lhs * CGFloat(rhs) 40 | } 41 | 42 | // MARK: - CGPoint 43 | 44 | public func + (_ lhs: CGPoint, _ rhs: CGPoint) -> CGPoint { 45 | .init(x: lhs.x + rhs.x, y: lhs.y + rhs.y) 46 | } 47 | 48 | public func += (_ lhs: inout CGPoint, _ rhs: CGPoint) { 49 | lhs = .init(x: lhs.x + rhs.x, y: lhs.y + rhs.y) 50 | } 51 | 52 | public func + (_ lhs: CGSize, _ rhs: CGFloat) -> CGSize { 53 | .init(width: lhs.width + rhs, height: lhs.height + rhs) 54 | } 55 | 56 | public func - (_ lhs: CGSize, _ rhs: CGFloat) -> CGSize { 57 | .init(width: lhs.width - rhs, height: lhs.height - rhs) 58 | } 59 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/PlatformColor+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2021/5/2. 3 | // Copyright (c) 2021 ktiays. All rights reserved. 4 | // 5 | 6 | #if canImport(UIKit) 7 | 8 | import UIKit 9 | public typealias PlatformColor = UIColor 10 | 11 | public extension PlatformColor { 12 | 13 | /// A color that reflects the accent color of the system or app. 14 | /// 15 | /// The accent color is a broad theme color applied to views and controls. 16 | /// You can set it at the application level by specifying an accent color in your app’s asset catalog. 17 | static let accentColor: UIColor? = .init(named: "AccentColor") 18 | 19 | convenience init(lightColor: PlatformColor, darkColor: PlatformColor) { 20 | self.init(dynamicProvider: { traitCollection in 21 | if traitCollection.userInterfaceStyle == .dark { 22 | return darkColor 23 | } else { 24 | return lightColor 25 | } 26 | }) 27 | } 28 | 29 | convenience init(integalRed: Int, green: Int, blue: Int, alpha: CGFloat) { 30 | self.init(red: max(0, min(CGFloat(integalRed) / 255, 1)), 31 | green: max(0, min(CGFloat(green) / 255, 1)), 32 | blue: max(0, min(CGFloat(blue) / 255, 1)), 33 | alpha: alpha) 34 | } 35 | 36 | convenience init?(hexString: String) { 37 | var trimmedHexString = hexString.trimmingCharacters(in: .whitespacesAndNewlines) 38 | 39 | if trimmedHexString.hasPrefix("#") { 40 | trimmedHexString.remove(at: trimmedHexString.startIndex) 41 | } 42 | 43 | guard trimmedHexString.count == 6 else { 44 | return nil 45 | } 46 | 47 | var rgbValue: UInt64 = 0 48 | guard Scanner(string: trimmedHexString).scanHexInt64(&rgbValue) else { 49 | return nil 50 | } 51 | 52 | self.init( 53 | red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0, 54 | green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0, 55 | blue: CGFloat(rgbValue & 0x0000FF) / 255.0, 56 | alpha: CGFloat(1.0) 57 | ) 58 | } 59 | 60 | } 61 | 62 | #elseif canImport(AppKit) 63 | 64 | import AppKit 65 | public typealias PlatformColor = NSColor 66 | 67 | public extension PlatformColor { 68 | 69 | convenience init(lightColor: PlatformColor, darkColor: PlatformColor) { 70 | self.init(name: nil, dynamicProvider: { appearance in 71 | if appearance.userInterfaceStyle == .dark { 72 | return darkColor 73 | } else { 74 | return lightColor 75 | } 76 | }) 77 | } 78 | 79 | convenience init(integalRed: Int, green: Int, blue: Int, alpha: CGFloat) { 80 | self.init(red: max(0, min(CGFloat(integalRed) / 255, 1)), 81 | green: max(0, min(CGFloat(green) / 255, 1)), 82 | blue: max(0, min(CGFloat(blue) / 255, 1)), 83 | alpha: alpha) 84 | } 85 | 86 | } 87 | 88 | public extension PlatformColor { 89 | 90 | /// The primary color to use for text labels. 91 | /// 92 | /// Use this color in the most important text labels of your user interface. 93 | /// You can also use it for other types of primary app content. 94 | /// 95 | /// This color is the same as `labelColor`. 96 | static let label: NSColor = .labelColor 97 | 98 | /// The color to use for separators between different sections of content. 99 | /// 100 | /// Do not use this color for split view dividers or window chrome dividers. 101 | /// 102 | /// This color is the same as `separatorColor`. 103 | static let separator: NSColor = .separatorColor 104 | 105 | } 106 | 107 | #endif 108 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/PlatformEdgeInsets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2023/8/13. 3 | // Copyright (c) 2023 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | #if canImport(UIKit) 8 | import UIKit 9 | #endif 10 | 11 | public extension PlatformEdgeInsets { 12 | var inverted: Self { 13 | .init(top: -top, left: -left, bottom: -bottom, right: -right) 14 | } 15 | 16 | init(horizontal: CGFloat = 0, vertical: CGFloat = 0) { 17 | self.init(top: vertical, left: horizontal, bottom: vertical, right: horizontal) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/PlatformFont+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/1/14. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | #if canImport(UIKit) 7 | import UIKit 8 | public typealias PlatformFont = UIFont 9 | #else 10 | import AppKit 11 | public typealias PlatformFont = NSFont 12 | #endif 13 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/String+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/2/10. 3 | // Copyright (c) 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public extension String { 9 | 10 | init(hexStringWith value: I, uppercase: Bool, paddingTo digits: Int? = nil) { 11 | var hexString = String(value, radix: 16, uppercase: true) 12 | if let padding = digits, hexString.count < padding { 13 | hexString = String(repeating: "0", count: padding - hexString.count) + hexString 14 | } 15 | self.init(hexString) 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/UIKit/UIApplication+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2021/6/21. 3 | // Copyright (c) 2021 Cyandev. All rights reserved. 4 | // 5 | 6 | #if os(iOS) 7 | import UIKit 8 | 9 | extension UIApplication: CyanExtending { } 10 | 11 | public extension ExtensionNamespace where Object: UIApplication { 12 | 13 | var keyWindow: UIWindow? { 14 | let activeWindowScenes = extendedObject.connectedScenes 15 | .compactMap({ return $0 as? UIWindowScene }) 16 | .filter({ $0.activationState == .foregroundActive }) 17 | guard let firstWindowScene = activeWindowScenes.first else { 18 | return nil 19 | } 20 | if #available(iOS 15.0, *) { 21 | return firstWindowScene.keyWindow 22 | } else { 23 | return firstWindowScene.windows 24 | .filter({ $0.isKeyWindow }) 25 | .first 26 | } 27 | } 28 | 29 | } 30 | #endif 31 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/UIKit/UIEdgeInsets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2023/8/13. 3 | // Copyright (c) 2023 ktiays. All rights reserved. 4 | // 5 | 6 | #if canImport(UIKit) 7 | import UIKit 8 | 9 | public typealias PlatformEdgeInsets = UIEdgeInsets 10 | #endif 11 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/UIKit/UIScreen+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/10/17. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | #if canImport(UIKit) 7 | 8 | import UIKit 9 | 10 | extension UIScreen { 11 | 12 | private static let _cornerRadiusKey: String = { 13 | let components = ["Radius", "Corner", "display", "_"] 14 | return components.reversed().joined() 15 | }() 16 | 17 | /// The corner radius of the display. Uses a private property of `UIScreen`, 18 | /// and may report 0 if the API changes. 19 | public var displayCornerRadius: CGFloat { 20 | guard let cornerRadius = self.value(forKey: Self._cornerRadiusKey) as? CGFloat else { 21 | return 0 22 | } 23 | return cornerRadius 24 | } 25 | } 26 | 27 | #endif 28 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/UIKit/UIView+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/10/17. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | #if canImport(UIKit) 7 | 8 | import UIKit 9 | 10 | extension UIView { 11 | 12 | public var cornerRadius: CGFloat { 13 | set { layer.cornerRadius = newValue } 14 | get { layer.cornerRadius } 15 | } 16 | 17 | public var cornerCurve: CALayerCornerCurve { 18 | set { layer.cornerCurve = newValue } 19 | get { layer.cornerCurve } 20 | } 21 | 22 | } 23 | 24 | #endif 25 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/UIKit/UIViewController+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2021/6/21. 3 | // Copyright (c) 2021 Cyandev. All rights reserved. 4 | // 5 | 6 | #if os(iOS) 7 | import UIKit 8 | 9 | extension UIViewController: CyanExtending { } 10 | 11 | public extension ExtensionNamespace where Object: UIViewController { 12 | 13 | var topViewController: UIViewController? { 14 | if let presentedViewController = extendedObject.presentedViewController { 15 | return presentedViewController.cyan.topViewController 16 | } 17 | return extendedObject 18 | } 19 | 20 | } 21 | #endif 22 | -------------------------------------------------------------------------------- /Sources/CyanExtensions/UIKit/UIWindow+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2021/6/21. 3 | // Copyright (c) 2021 Cyandev. All rights reserved. 4 | // 5 | 6 | #if os(iOS) 7 | import UIKit 8 | 9 | extension UIWindow: CyanExtending { } 10 | 11 | public extension ExtensionNamespace where Object: UIWindow { 12 | 13 | var topViewController: UIViewController? { 14 | return extendedObject.rootViewController?.cyan.topViewController 15 | } 16 | 17 | } 18 | #endif 19 | -------------------------------------------------------------------------------- /Sources/CyanKit/Exports.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/1/13. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | @_exported import CyanExtensions 7 | @_exported import CyanUtils 8 | @_exported import CyanCombine 9 | @_exported import CyanConcurrency 10 | @_exported import CyanSwiftUI 11 | @_exported import CyanUI 12 | -------------------------------------------------------------------------------- /Sources/CyanSwiftUI/AnimatedClickable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/1/22. 3 | // Copyright (c) 2021 Cyandev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | public struct AnimatedClickableConfiguration { 9 | 10 | public var activeAnimation: Animation 11 | public var identityAnimation: Animation 12 | 13 | public private(set) var activeModifier: AnyViewModifier 14 | public private(set) var identityModifier: AnyViewModifier 15 | 16 | /// A default configuration with spring animation and highlight effect. 17 | public static let highlight = Self.empty().activeOpacity(0.3) 18 | 19 | /// An empty configuration with the given animations, which has no active effects. 20 | public static func empty( 21 | activeAnimation: Animation = .spring(response: 0.1), 22 | identityAnimation: Animation = .spring(response: 0.6) 23 | ) -> Self { 24 | return .init( 25 | activeAnimation: activeAnimation, 26 | identityAnimation: identityAnimation, 27 | activeModifier: .init(), 28 | identityModifier: .init() 29 | ) 30 | } 31 | 32 | public func combined(with modifierBuilder: (_ active: Bool) -> E) -> Self where E: ViewModifier { 33 | var copy = self 34 | copy.activeModifier = copy.activeModifier.typeErasedConcat(modifierBuilder(true)) 35 | copy.identityModifier = copy.identityModifier.typeErasedConcat(modifierBuilder(false)) 36 | return copy 37 | } 38 | 39 | public func activeScale(_ scale: CGFloat) -> Self { 40 | combined { active in 41 | AnyViewModifier { $0.scaleEffect(active ? scale : 1) } 42 | } 43 | } 44 | 45 | public func activeOpacity(_ opacity: CGFloat) -> Self { 46 | combined { active in 47 | AnyViewModifier { 48 | $0.compositingGroup() 49 | .opacity(active ? opacity : 1) 50 | } 51 | } 52 | } 53 | 54 | } 55 | 56 | public struct AnimatedClickableModifier: ViewModifier { 57 | 58 | public let configuration: AnimatedClickableConfiguration 59 | public let action: () -> () 60 | 61 | public init(configuration: AnimatedClickableConfiguration, action: @escaping () -> ()) { 62 | self.configuration = configuration 63 | self.action = action 64 | } 65 | 66 | public func body(content: Content) -> some View { 67 | _AnimatedClickableView(content, configuration: configuration, action: action) 68 | } 69 | 70 | } 71 | 72 | public extension View { 73 | 74 | func animatedClickable( 75 | configuration: AnimatedClickableConfiguration = .highlight, 76 | action: @escaping () -> () 77 | ) -> some View { 78 | modifier(AnimatedClickableModifier(configuration: configuration, action: action)) 79 | } 80 | 81 | } 82 | 83 | #if os(iOS) 84 | fileprivate struct _AnimatedClickableIOSGestureView: UIViewRepresentable { 85 | 86 | struct Event { 87 | let locationInView: CGPoint 88 | let viewBounds: CGRect 89 | let isEnded: Bool 90 | let isCancelled: Bool 91 | } 92 | 93 | class _UIView: UIView { 94 | 95 | var eventHandler: ((Event) -> ())? 96 | 97 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 98 | super.touchesBegan(touches, with: event) 99 | handleEvent(with: touches, isEnded: false) 100 | } 101 | 102 | override func touchesMoved(_ touches: Set, with event: UIEvent?) { 103 | super.touchesMoved(touches, with: event) 104 | handleEvent(with: touches, isEnded: false) 105 | } 106 | 107 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 108 | super.touchesEnded(touches, with: event) 109 | handleEvent(with: touches, isEnded: true) 110 | } 111 | 112 | override func touchesCancelled(_ touches: Set, with event: UIEvent?) { 113 | super.touchesCancelled(touches, with: event) 114 | handleEvent(with: touches, isEnded: true, isCancelled: true) 115 | } 116 | 117 | private func handleEvent(with touches: Set, isEnded: Bool, isCancelled: Bool = false) { 118 | guard let locationInView = touches.first?.location(in: self) else { 119 | return 120 | } 121 | eventHandler?(.init( 122 | locationInView: locationInView, 123 | viewBounds: bounds, 124 | isEnded: isEnded, 125 | isCancelled: isCancelled 126 | )) 127 | } 128 | 129 | } 130 | 131 | typealias UIViewType = _UIView 132 | 133 | let handler: (Event) -> () 134 | 135 | func makeUIView(context: Context) -> _UIView { 136 | return _UIView() 137 | } 138 | 139 | func updateUIView(_ uiView: _UIView, context: Context) { 140 | uiView.eventHandler = handler 141 | } 142 | 143 | } 144 | #endif 145 | 146 | private struct _AnimatedClickableView: View where V: View { 147 | 148 | private let content: V 149 | private let configuration: AnimatedClickableConfiguration 150 | private let action: () -> () 151 | 152 | @State private var viewSize: CGSize = .zero 153 | @State private var isPressed: Bool = false 154 | @State private var isPointInside: Bool = false 155 | 156 | init(_ content: V, configuration: AnimatedClickableConfiguration, action: @escaping () -> ()) { 157 | self.content = content 158 | self.configuration = configuration 159 | self.action = action 160 | } 161 | 162 | var body: some View { 163 | content 164 | .overlay(sizeReader()) 165 | .modifier(isPointInside 166 | ? configuration.activeModifier 167 | : configuration.identityModifier) 168 | #if os(iOS) 169 | .overlay(gestureView()) 170 | #elseif os(macOS) 171 | .highPriorityGesture(dragGesture()) 172 | #endif 173 | } 174 | 175 | #if os(iOS) 176 | private func gestureView() -> some View { 177 | return _AnimatedClickableIOSGestureView { event in 178 | if event.isEnded && !event.isCancelled && isPointInside { 179 | action() 180 | } 181 | 182 | withAnimation(isPressed 183 | ? configuration.identityAnimation 184 | : configuration.activeAnimation) { 185 | if event.isEnded { 186 | isPressed = false 187 | isPointInside = false 188 | return 189 | } 190 | 191 | let activeZone = CGRect(origin: .zero, size: viewSize) 192 | .insetBy(dx: -24, dy: -24) 193 | isPointInside = activeZone.contains(event.locationInView) 194 | } 195 | 196 | if !event.isEnded { 197 | isPressed = true 198 | } 199 | } 200 | } 201 | #endif 202 | 203 | #if os(macOS) 204 | private func dragGesture() -> some Gesture { 205 | DragGesture(minimumDistance: 0) 206 | .onChanged { value in 207 | withAnimation(isPressed 208 | ? configuration.identityAnimation 209 | : configuration.activeAnimation) { 210 | let activeZone = CGRect(origin: .zero, size: viewSize) 211 | .insetBy(dx: -24, dy: -24) 212 | isPointInside = activeZone.contains(value.location) 213 | } 214 | isPressed = true 215 | } 216 | .onEnded { _ in 217 | if isPointInside { 218 | action() 219 | } 220 | withAnimation(configuration.identityAnimation) { 221 | isPressed = false 222 | isPointInside = false 223 | } 224 | } 225 | } 226 | #endif 227 | 228 | private func sizeReader() -> some View { 229 | GeometryReader { proxy in 230 | Color.clear 231 | .onChange(of: proxy.size) { newValue in 232 | viewSize = newValue 233 | } 234 | .onAppear { 235 | viewSize = proxy.size 236 | } 237 | } 238 | } 239 | 240 | } 241 | -------------------------------------------------------------------------------- /Sources/CyanSwiftUI/AnyViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/1/22. 3 | // Copyright (c) 2021 Cyandev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | import CyanExtensions 8 | 9 | public struct AnyViewModifier: ViewModifier { 10 | 11 | private let bodyBuilder: (Content) -> AnyView 12 | 13 | public init(@ViewBuilder _ viewModifier: @escaping (Content) -> V) where V: View { 14 | bodyBuilder = { content in 15 | AnyView(content |> viewModifier) 16 | } 17 | } 18 | 19 | public init(_ viewModifier: V) where V: ViewModifier { 20 | bodyBuilder = { content in 21 | AnyView(content.modifier(viewModifier)) 22 | } 23 | } 24 | 25 | public init() { 26 | bodyBuilder = { AnyView($0) } 27 | } 28 | 29 | public func body(content: Content) -> some View { 30 | content |> bodyBuilder 31 | } 32 | 33 | public func typeErasedConcat(_ modifier: T) -> AnyViewModifier where T: ViewModifier { 34 | return .init(concat(modifier)) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Sources/CyanSwiftUI/EditMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2023/8/13. 3 | // Copyright (c) 2023 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(iOS) 7 | import SwiftUI 8 | import UIKit 9 | import CyanUtils 10 | import CyanExtensions 11 | import ObjectiveC 12 | 13 | @available(iOS 15.0, *) 14 | public struct EditMenuModifier: ViewModifier { 15 | /// A rectangle with edges moved outwards by the given insets. 16 | private let insets: UIEdgeInsets 17 | private let items: () -> [EditMenuAction] 18 | 19 | public init(insets: UIEdgeInsets = .zero, @ArrayBuilder items: @escaping () -> [EditMenuAction]) { 20 | self.insets = insets 21 | self.items = items 22 | } 23 | 24 | public func body(content: Content) -> some View { 25 | content.overlay { 26 | GeometryReader { proxy in 27 | Color.clear 28 | .contentShape(Rectangle()) 29 | .onTapGesture { 30 | // If empty `onTapGesture` block is not added, the outer `List` will not be able to scroll. 31 | // This may be a bug in SwiftUI. 32 | } 33 | .onLongPressGesture { 34 | let items = items() 35 | if items.isEmpty { return } 36 | 37 | let menuController = UIMenuController.shared 38 | let dummyView = editMenuDummyView() 39 | dummyView.removeFromSuperview() 40 | guard let keyWindow = UIApplication.shared.cyan.keyWindow else { 41 | return 42 | } 43 | dummyView.frame = proxy.frame(in: .global).inflate(by: insets) 44 | dummyView.actionHandler = { [unowned dummyView] index in 45 | defer { dummyView.resignFirstResponder() } 46 | if index >= items.count { return } 47 | items[index].action() 48 | } 49 | keyWindow.addSubview(dummyView) 50 | dummyView.becomeFirstResponder() 51 | 52 | menuController.menuItems = items.enumerated().map { (index, action) in 53 | return .init( 54 | title: action.title ?? "", 55 | action: _DummyView.selector(for: index) 56 | ) 57 | } 58 | menuController.showMenu(from: dummyView, rect: dummyView.bounds) 59 | } 60 | } 61 | } 62 | } 63 | 64 | private func editMenuDummyView() -> _DummyView { 65 | if let view = UIMenuController.shared.dummyView { 66 | return view 67 | } 68 | let view = _DummyView() 69 | UIMenuController.shared.dummyView = view 70 | return view 71 | } 72 | 73 | fileprivate class _DummyView: UIView { 74 | static var key: Void? = nil 75 | static let selectorPrefix = "__dynamicallyInvokeAction🪵" 76 | 77 | var actionHandler: ((Int) -> Void)? 78 | 79 | static func selector(for index: Int) -> Selector { 80 | Selector("\(selectorPrefix)\(index)") 81 | } 82 | 83 | override init(frame: CGRect) { 84 | super.init(frame: frame) 85 | self.isUserInteractionEnabled = false 86 | } 87 | 88 | required init?(coder: NSCoder) { 89 | fatalError("init(coder:) has not been implemented") 90 | } 91 | 92 | override var canBecomeFirstResponder: Bool { true } 93 | 94 | class func willRespond(to selector: Selector) -> Bool { 95 | NSStringFromSelector(selector).hasPrefix(selectorPrefix) 96 | } 97 | 98 | override class func resolveInstanceMethod(_ selector: Selector!) -> Bool { 99 | func resolveSelector(_ selector: Selector) -> Int? { 100 | let name = NSStringFromSelector(selector) 101 | guard name.hasPrefix(_DummyView.selectorPrefix), 102 | let index = Int(name.dropFirst(_DummyView.selectorPrefix.count)) 103 | else { 104 | return nil 105 | } 106 | return index 107 | } 108 | 109 | func dynamicallyInvokeAction🪵(_self: Any, _cmd: Selector, sender: Any) { 110 | guard let self = _self as? _DummyView else { 111 | assertionFailure("Invalid receiver for selector `\(_cmd)`") 112 | return 113 | } 114 | guard let index = resolveSelector(_cmd) else { 115 | assertionFailure("Invalid selector `\(_cmd)`") 116 | return 117 | } 118 | self.actionHandler?(index) 119 | } 120 | 121 | guard resolveSelector(selector) != nil else { 122 | return super.resolveInstanceMethod(selector) 123 | } 124 | 125 | return class_addMethod( 126 | self, 127 | selector, 128 | unsafeBitCast( 129 | dynamicallyInvokeAction🪵 as (@convention(c) (Any, Selector, Any) -> Void), 130 | to: IMP.self 131 | ), 132 | "v@:@" 133 | ) 134 | } 135 | } 136 | } 137 | 138 | @available(iOS 15.0, *) 139 | private extension UIMenuController { 140 | var dummyView: EditMenuModifier._DummyView? { 141 | set { 142 | objc_setAssociatedObject(self, &EditMenuModifier._DummyView.key, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 143 | } 144 | get { 145 | objc_getAssociatedObject(self, &EditMenuModifier._DummyView.key) as? EditMenuModifier._DummyView 146 | } 147 | } 148 | } 149 | 150 | @available(iOS 15.0, *) 151 | public struct EditMenuAction { 152 | public let title: String? 153 | public let action: () -> Void 154 | 155 | public init(_ title: String?, action: @escaping () -> Void) { 156 | self.title = title 157 | self.action = action 158 | } 159 | } 160 | 161 | @available(iOS 15.0, *) 162 | public extension View { 163 | func editMenu(insets: UIEdgeInsets = .zero, @ArrayBuilder _ actions: @escaping () -> [EditMenuAction]) -> some View { 164 | modifier(EditMenuModifier(insets: insets, items: actions)) 165 | } 166 | } 167 | #endif 168 | -------------------------------------------------------------------------------- /Sources/CyanSwiftUI/Extensions/Color.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/3/20. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | import CyanExtensions 8 | 9 | public extension Color { 10 | 11 | init(platformColor: PlatformColor) { 12 | #if canImport(UIKit) 13 | if #available(iOS 15.0, tvOS 15.0, *) { 14 | self.init(uiColor: platformColor) 15 | } else { 16 | self.init(platformColor) 17 | } 18 | #else 19 | if #available(macOS 12.0, *) { 20 | self.init(nsColor: platformColor) 21 | } else { 22 | self.init(platformColor) 23 | } 24 | #endif 25 | } 26 | 27 | @inlinable init(lightColor: PlatformColor, darkColor: PlatformColor) { 28 | self.init(platformColor: .init(lightColor: lightColor, darkColor: darkColor)) 29 | } 30 | 31 | } 32 | 33 | // MARK: - Constants 34 | 35 | // Adaptable Colors 36 | public extension Color { 37 | 38 | /// A context-dependent red color that automatically adapts to the current trait environment. 39 | static let systemRed: Color = .init(platformColor: .systemRed) 40 | 41 | /// A context-dependent orange color that automatically adapts to the current trait environment. 42 | static let systemOrange: Color = .init(platformColor: .systemOrange) 43 | 44 | /// A context-dependent yellow color that automatically adapts to the current trait environment. 45 | static let systemYellow: Color = .init(platformColor: .systemYellow) 46 | 47 | /// A context-dependent green color that automatically adapts to the current trait environment. 48 | static let systemGreen: Color = .init(platformColor: .systemGreen) 49 | 50 | /// A context-dependent blue color that automatically adapts to the current trait environment. 51 | static let systemBlue: Color = .init(platformColor: .systemBlue) 52 | 53 | /// A context-dependent indigo color that automatically adapts to the current trait environment. 54 | static let systemIndigo: Color = .init(platformColor: .systemIndigo) 55 | 56 | /// A context-dependent purple color that automatically adapts to the current trait environment. 57 | static let systemPurple: Color = .init(platformColor: .systemPurple) 58 | 59 | /// A context-dependent pink color that automatically adapts to the current trait environment. 60 | static let systemPink: Color = .init(platformColor: .systemPink) 61 | 62 | /// A context-dependent teal color that automatically adapts to the current trait environment. 63 | static let systemTeal: Color = .init(platformColor: .systemTeal) 64 | 65 | /// A context-dependent cyan color that automatically adapts to the current trait environment. 66 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) 67 | static let systemCyan: Color = .init(platformColor: .systemCyan) 68 | 69 | /// A context-dependent mint color that automatically adapts to the current trait environment. 70 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) 71 | static let systemMint: Color = .init(platformColor: .systemMint) 72 | 73 | 74 | /* Gray Colors */ 75 | 76 | /// A context-dependent gray color that automatically adapts to the current trait environment. 77 | static let systemGray: Color = .init(platformColor: .systemGray) 78 | 79 | #if canImport(UIKit) && !os(tvOS) 80 | 81 | /// A second-level shade of gray that adapts to the environment. 82 | /// 83 | /// This color adapts to the current environment. 84 | /// In light environments, this gray is slightly lighter than `systemGray`. 85 | /// In dark environments, this gray is slightly darker than `systemGray`. 86 | static let systemGray2: Color = .init(platformColor: .systemGray2) 87 | 88 | /// A third-level shade of gray that adapts to the environment. 89 | /// 90 | /// This color adapts to the current environment. 91 | /// In light environments, this gray is slightly lighter than `systemGray2`. 92 | /// In dark environments, this gray is slightly darker than `systemGray2`. 93 | static let systemGray3: Color = .init(platformColor: .systemGray3) 94 | 95 | /// A fourth-level shade of gray that adapts to the environment. 96 | /// 97 | /// This color adapts to the current environment. 98 | /// In light environments, this gray is slightly lighter than `systemGray3`. 99 | /// In dark environments, this gray is slightly darker than `systemGray3`. 100 | static let systemGray4: Color = .init(platformColor: .systemGray4) 101 | 102 | /// A fifth-level shade of gray that adapts to the environment. 103 | /// 104 | /// This color adapts to the current environment. 105 | /// In light environments, this gray is slightly lighter than `systemGray4`. 106 | /// In dark environments, this gray is slightly darker than `systemGray4`. 107 | static let systemGray5: Color = .init(platformColor: .systemGray5) 108 | 109 | /// A sixth-level shade of gray that adapts to the environment. 110 | /// 111 | /// This color adapts to the current environment, and is close in color to systemBackground. 112 | /// In light environments, this gray is slightly lighter than `systemGray5`. 113 | /// In dark environments, this gray is slightly darker than `systemGray5`. 114 | static let systemGray6: Color = .init(platformColor: .systemGray6) 115 | 116 | #elseif canImport(AppKit) 117 | 118 | /// A second-level shade of gray that adapts to the environment. 119 | /// 120 | /// This color adapts to the current environment. 121 | /// In light environments, this gray is slightly lighter than `systemGray`. 122 | /// In dark environments, this gray is slightly darker than `systemGray`. 123 | static let systemGray2: Color = .init(lightColor: .init(integalRed: 174, green: 174, blue: 178, alpha: 1), 124 | darkColor: .init(integalRed: 99, green: 99, blue: 102, alpha: 1)) 125 | 126 | /// A third-level shade of gray that adapts to the environment. 127 | /// 128 | /// This color adapts to the current environment. 129 | /// In light environments, this gray is slightly lighter than `systemGray2`. 130 | /// In dark environments, this gray is slightly darker than `systemGray2`. 131 | static let systemGray3: Color = .init(lightColor: .init(integalRed: 199, green: 199, blue: 204, alpha: 1), 132 | darkColor: .init(integalRed: 72, green: 72, blue: 74, alpha: 1)) 133 | 134 | /// A fourth-level shade of gray that adapts to the environment. 135 | /// 136 | /// This color adapts to the current environment. 137 | /// In light environments, this gray is slightly lighter than `systemGray3`. 138 | /// In dark environments, this gray is slightly darker than `systemGray3`. 139 | static let systemGray4: Color = .init(lightColor: .init(integalRed: 209, green: 209, blue: 214, alpha: 1), 140 | darkColor: .init(integalRed: 58, green: 58, blue: 60, alpha: 1)) 141 | 142 | /// A fifth-level shade of gray that adapts to the environment. 143 | /// 144 | /// This color adapts to the current environment. 145 | /// In light environments, this gray is slightly lighter than `systemGray4`. 146 | /// In dark environments, this gray is slightly darker than `systemGray4`. 147 | static let systemGray5: Color = .init(lightColor: .init(integalRed: 229, green: 229, blue: 234, alpha: 1), 148 | darkColor: .init(integalRed: 44, green: 44, blue: 46, alpha: 1)) 149 | 150 | /// A sixth-level shade of gray that adapts to the environment. 151 | /// 152 | /// This color adapts to the current environment, and is close in color to systemBackground. 153 | /// In light environments, this gray is slightly lighter than `systemGray5`. 154 | /// In dark environments, this gray is slightly darker than `systemGray5`. 155 | static let systemGray6: Color = .init(lightColor: .init(integalRed: 242, green: 242, blue: 247, alpha: 1), 156 | darkColor: .init(integalRed: 28, green: 28, blue: 30, alpha: 1)) 157 | 158 | #endif 159 | 160 | } 161 | 162 | // Fixed Colors 163 | public extension Color { 164 | 165 | /// A color object with RGB values of 1.0, 0.0, and 1.0, and an alpha value of 1.0. 166 | static let magenta: Color = .init(platformColor: .magenta) 167 | 168 | /// A color object with a grayscale value of 1/3 and an alpha value of 1.0. 169 | static let darkGray: Color = .init(platformColor: .darkGray) 170 | 171 | /// A color object with a grayscale value of 2/3 and an alpha value of 1.0. 172 | static let lightGray: Color = .init(platformColor: .lightGray) 173 | 174 | } 175 | 176 | #if os(iOS) 177 | // UI Element Colors 178 | @available(iOS 13.0, *) 179 | public extension Color { 180 | 181 | @available(iOS 15.0, *) 182 | static let tintColor: Color = .init(platformColor: .tintColor) 183 | 184 | 185 | /* Label Colors */ 186 | 187 | /// The color for text labels that contain primary content. 188 | static let label: Color = .init(platformColor: .label) 189 | 190 | /// The color for text labels that contain secondary content. 191 | static let secondaryLabel: Color = .init(platformColor: .secondaryLabel) 192 | 193 | /// The color for text labels that contain tertiary content. 194 | static let tertiaryLabel: Color = .init(platformColor: .tertiaryLabel) 195 | 196 | /// The color for text labels that contain quaternary content. 197 | static let quaternaryLabel: Color = .init(platformColor: .quaternaryLabel) 198 | 199 | 200 | /* Link Color */ 201 | 202 | /// The specified color for links. 203 | static let link: Color = .init(platformColor: .link) 204 | 205 | 206 | /* Text Colors */ 207 | 208 | /// The color for placeholder text in controls or text views. 209 | static let placeholderText: Color = .init(platformColor: .placeholderText) 210 | 211 | 212 | /* Separator Colors */ 213 | 214 | /// The color for thin borders or divider lines that allows some underlying content to be visible. 215 | /// 216 | /// This color may be partially transparent to allow the underlying content to show through. 217 | /// It adapts to the underlying trait environment. 218 | static let separator: Color = .init(platformColor: .separator) 219 | 220 | /// The color for borders or divider lines that hides any underlying content. 221 | /// 222 | /// This color is always opaque. It adapts to the underlying trait environment. 223 | static let opaqueSeparator: Color = .init(platformColor: .opaqueSeparator) 224 | 225 | 226 | /* Standard Content Background Colors */ 227 | 228 | /// The color for the main background of your interface. 229 | /// 230 | /// Use this color for standard table views and designs that have a white primary background in a light environment. 231 | static let systemBackground: Color = .init(platformColor: .systemBackground) 232 | 233 | /// The color for content layered on top of the main background. 234 | /// 235 | /// Use this color for standard table views and designs that have a white primary background in a light environment. 236 | static let secondarySystemBackground: Color = .init(platformColor: .secondarySystemBackground) 237 | 238 | /// The color for content layered on top of secondary backgrounds. 239 | /// 240 | /// Use this color for standard table views and designs that have a white primary background in a light environment. 241 | static let tertiarySystemBackground: Color = .init(platformColor: .tertiarySystemBackground) 242 | 243 | 244 | /* Grouped Content Background Colors */ 245 | 246 | /// The color for the main background of your grouped interface. 247 | /// 248 | /// Use this color for grouped content, including table views and platter-based designs. 249 | static let systemGroupedBackground: Color = .init(platformColor: .systemGroupedBackground) 250 | 251 | /// The color for content layered on top of the main background of your grouped interface. 252 | /// 253 | /// Use this color for grouped content, including table views and platter-based designs. 254 | static let secondarySystemGroupedBackground: Color = .init(platformColor: .secondarySystemGroupedBackground) 255 | 256 | /// The color for content layered on top of secondary backgrounds of your grouped interface. 257 | /// 258 | /// Use this color for grouped content, including table views and platter-based designs. 259 | static let tertiarySystemGroupedBackground: Color = .init(platformColor: .tertiarySystemGroupedBackground) 260 | 261 | 262 | /* Fill Colors */ 263 | 264 | /// An overlay fill color for thin and small shapes. 265 | /// 266 | /// Use system fill colors for items situated on top of an existing background color. 267 | /// System fill colors incorporate transparency to allow the background color to show through. 268 | /// 269 | /// Use this color to fill thin or small shapes, such as the track of a slider. 270 | static let systemFill: Color = .init(platformColor: .systemFill) 271 | 272 | /// An overlay fill color for medium-size shapes. 273 | /// 274 | /// Use system fill colors for items situated on top of an existing background color. 275 | /// System fill colors incorporate transparency to allow the background color to show through. 276 | /// 277 | /// Use this color to fill medium-size shapes, such as the background of a switch. 278 | static let secondarySystemFill: Color = .init(platformColor: .secondarySystemFill) 279 | 280 | /// An overlay fill color for large shapes. 281 | /// 282 | /// Use system fill colors for items situated on top of an existing background color. 283 | /// System fill colors incorporate transparency to allow the background color to show through. 284 | /// 285 | /// Use this color to fill large shapes, such as input fields, search bars, or buttons. 286 | static let tertiarySystemFill: Color = .init(platformColor: .tertiarySystemFill) 287 | 288 | /// An overlay fill color for large areas that contain complex content. 289 | /// 290 | /// Use system fill colors for items situated on top of an existing background color. 291 | /// System fill colors incorporate transparency to allow the background color to show through. 292 | /// 293 | /// Use this color to fill large areas that contain complex content, such as an expanded table cell. 294 | static let quaternarySystemFill: Color = .init(platformColor: .quaternarySystemFill) 295 | 296 | 297 | /* Nonadaptable Colors */ 298 | 299 | /// The nonadaptable system color for text on a dark background. 300 | /// 301 | /// This color doesn’t adapt to changes in the underlying trait environment. 302 | static let lightText: Color = .init(platformColor: .lightText) 303 | 304 | /// The nonadaptable system color for text on a light background. 305 | /// 306 | /// This color doesn’t adapt to changes in the underlying trait environment. 307 | static let darkText: Color = .init(platformColor: .darkText) 308 | 309 | } 310 | #elseif canImport(AppKit) 311 | @available(macOS 10.10, *) 312 | public extension Color { 313 | 314 | /* Label Colors */ 315 | 316 | /// The primary color to use for text labels. 317 | /// 318 | /// Use this color in the most important text labels of your user interface. 319 | /// You can also use it for other types of primary app content. 320 | static let label: Color = .init(platformColor: .labelColor) 321 | 322 | /// The secondary color to use for text labels. 323 | /// 324 | /// Use this color in text fields that contain less important text in your user interface. 325 | /// For example, you might use this in labels that display subheads or additional information. 326 | /// You can also use it for other types of secondary app content. 327 | static let secondaryLabel: Color = .init(platformColor: .secondaryLabelColor) 328 | 329 | /// The tertiary color to use for text labels. 330 | /// 331 | /// Use this color for disabled text and for other less important text in your interface. 332 | /// You can also use it for other types of tertiary app content. 333 | static let tertiaryLabel: Color = .init(platformColor: .tertiaryLabelColor) 334 | 335 | /// The quaternary color to use for text labels and separators. 336 | /// 337 | /// Use this color for the least important text in your interface and for separators between text items. 338 | /// For example, you would use this color for secondary text that is disabled. 339 | /// You can also use it for other types of quaternary app content. 340 | static let quaternaryLabel: Color = .init(platformColor: .quaternaryLabelColor) 341 | 342 | 343 | /* Text Colors */ 344 | 345 | /// The color to use for text. 346 | /// 347 | /// When text is selected, its color changes to the return value of `selectedText`. 348 | static let text: Color = .init(platformColor: .textColor) 349 | 350 | /// The color to use for placeholder text in controls or text views. 351 | static let placeholderText: Color = .init(platformColor: .placeholderTextColor) 352 | 353 | /// The color to use for selected text. 354 | static let selectedText: Color = .init(platformColor: .selectedTextColor) 355 | 356 | /// The color to use for the background area behind text. 357 | /// 358 | /// When text is selected, its background color changes to the return value of `selectedTextBackground`. 359 | /// With Desktop Tinting, the system modifies this color dynamically by incorporating some of the color from the underlying desktop image. 360 | /// The system does not apply this dynamic tinting effect to other types of views. 361 | static let textBackground: Color = .init(platformColor: .textBackgroundColor) 362 | 363 | /// The color to use for the background of selected text. 364 | static let selectedTextBackground: Color = .init(platformColor: .selectedTextBackgroundColor) 365 | 366 | /// The color to use for the keyboard focus ring around controls. 367 | static let keyboardFocusIndicator: Color = .init(platformColor: .keyboardFocusIndicatorColor) 368 | 369 | /// The color to use for selected text in an unemphasized context. 370 | /// 371 | /// Use this color when the window containing the text is not key, or when the view containing the text does not have key focus. 372 | @available(macOS 10.14, *) 373 | static let unemphasizedSelectedTextBackground: Color = .init(platformColor: .unemphasizedSelectedTextBackgroundColor) 374 | 375 | /// The color to use for the text background in an unemphasized context. 376 | /// 377 | /// Use this color when the window containing the text is not key, or when the view containing the text does not have key focus. 378 | @available(macOS 10.14, *) 379 | static let unemphasizedSelectedText: Color = .init(platformColor: .unemphasizedSelectedTextColor) 380 | 381 | 382 | /* Content Colors */ 383 | 384 | /// The color to use for links. 385 | static let link: Color = .init(platformColor: .linkColor) 386 | 387 | /// The color to use for separators between different sections of content. 388 | /// 389 | /// Do not use this color for split view dividers or window chrome dividers. 390 | @available(macOS 10.14, *) 391 | static let separator: Color = .init(platformColor: .separatorColor) 392 | 393 | /// The color to use for the background of selected and emphasized content. 394 | @available(macOS 10.14, *) 395 | static let selectedContentBackground: Color = .init(platformColor: .selectedContentBackgroundColor) 396 | 397 | /// The color to use for selected and unemphasized content. 398 | /// 399 | /// Use this color when the window containing the content is not key, or when the view containing the content does not have key focus. 400 | @available(macOS 10.14, *) 401 | static let unemphasizedSelectedContentBackground: Color = .init(platformColor: .unemphasizedSelectedContentBackgroundColor) 402 | 403 | 404 | /* Menu Colors */ 405 | 406 | /// The color to use for the text in menu items. 407 | /// 408 | /// The system color used for text in selected menu items. 409 | static let selectedMenuItemText: Color = .init(platformColor: .selectedMenuItemTextColor) 410 | 411 | 412 | /* Table Colors */ 413 | 414 | /// The color to use for the optional gridlines, such as those in a table view. 415 | /// 416 | /// The system color used for gridlines. 417 | static let gridColor: Color = .init(platformColor: .gridColor) 418 | 419 | /// The color to use for text in header cells in table views and outline views. 420 | /// 421 | /// The system color used for text in header cells in table and outline views. 422 | static let headerText: Color = .init(platformColor: .headerTextColor) 423 | 424 | /// The colors to use for alternating content, typically found in table views and collection views. 425 | @available(macOS 10.14, *) 426 | static let alternatingContentBackgroundColors: [Color] = NSColor.alternatingContentBackgroundColors.map { .init(platformColor: $0) } 427 | 428 | 429 | /* Control Colors */ 430 | 431 | /// The color to use for the flat surfaces of a control. 432 | /// 433 | /// The system color used for the flat surfaces of a control. 434 | /// By default, the control color is a pattern color that will draw the ruled lines for the window background, which is the same as returned by `windowBackground`. 435 | /// 436 | /// If you use controlColor assuming that it is a solid, you may have an incorrect appearance. You should use `lightGray` in its place. 437 | static let control: Color = .init(platformColor: .controlColor) 438 | 439 | /// The user's current accent color preference. 440 | /// 441 | /// Users set the accent color in the General pane of system preferences. 442 | /// Do not make assumptions about the color space associated with this color. 443 | @available(macOS 10.14, *) 444 | static let controlAccentColor: Color = .init(platformColor: .controlAccentColor) 445 | 446 | /// The color to use for the background of large controls, such as scroll views or table views. 447 | /// 448 | /// With Desktop Tinting, the system modifies this color dynamically by incorporating some of the color from the underlying desktop image. 449 | /// The system does not apply this dynamic tinting effect to other types of views. 450 | static let controlBackground: Color = .init(platformColor: .controlBackgroundColor) 451 | 452 | /// The color to use for text on enabled controls. 453 | /// 454 | /// The color used for text on enabled controls. 455 | static let controlText: Color = .init(platformColor: .controlTextColor) 456 | 457 | /// The color to use for text on disabled controls. 458 | /// 459 | /// The color used for text on disabled controls. 460 | static let disabledControlText: Color = .init(platformColor: .disabledControlTextColor) 461 | 462 | /// The color to use for the face of a selected control—that is, a control that has been clicked or is being dragged. 463 | static let selectedControl: Color = .init(platformColor: .selectedControlColor) 464 | 465 | /// The color to use for text in a selected control—that is, a control being clicked or dragged. 466 | static let selectedControlText: Color = .init(platformColor: .selectedControlTextColor) 467 | 468 | /// The colors to use for alternating content, typically found in table views and collection views. 469 | static let alternateSelectedControlText: Color = .init(platformColor: .alternateSelectedControlTextColor) 470 | 471 | /// The patterned color to use for the background of a scrubber control. 472 | @available(macOS 10.12.2, *) 473 | static let scrubberTexturedBackground: Color = .init(platformColor: .scrubberTexturedBackground) 474 | 475 | 476 | /* Window Colors */ 477 | 478 | /// The color to use for the window background. 479 | /// 480 | /// The window background color. 481 | /// With Desktop Tinting, the system modifies this color dynamically by incorporating some of the color from the underlying desktop image. 482 | /// The system does not apply this dynamic tinting effect to other types of views. 483 | static let windowBackground: Color = .init(platformColor: .windowBackgroundColor) 484 | 485 | /// The color to use for text in a window's frame. 486 | /// 487 | /// The color used for text in window frames. 488 | static let windowFrameText: Color = .init(platformColor: .windowFrameTextColor) 489 | 490 | /// The color to use in the area beneath your window's views. 491 | /// 492 | /// Use this color to fill the backdrop underneath your app's main content. 493 | /// 494 | /// With Desktop Tinting, the system modifies this color dynamically by incorporating some of the color from the underlying desktop image. 495 | /// The system does not apply this dynamic tinting effect to other types of views. 496 | static let underPageBackground: Color = .init(platformColor: .underPageBackgroundColor) 497 | 498 | 499 | /* Highlights and Shadows */ 500 | 501 | /// The highlight color to use for the bubble that shows inline search result values. 502 | @available(macOS 10.13, *) 503 | static let findHighlight: Color = .init(platformColor: .findHighlightColor) 504 | 505 | /// The color to use as a virtual light source on the screen. 506 | /// 507 | /// The system color for the virtual light source on the screen. 508 | static let highlight: Color = .init(platformColor: .highlightColor) 509 | 510 | /// The color to use for virtual shadows cast by raised objects on the screen. 511 | /// 512 | /// The system color for the virtual shadows case by raised objects on the screen. 513 | static let shadow: Color = .init(platformColor: .shadowColor) 514 | 515 | } 516 | #endif 517 | -------------------------------------------------------------------------------- /Sources/CyanSwiftUI/HostingViewReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/1/22. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | #if os(macOS) 9 | import AppKit 10 | typealias ViewRepresentable = NSViewRepresentable 11 | #else 12 | import UIKit 13 | typealias ViewRepresentable = UIViewRepresentable 14 | #endif 15 | 16 | public struct HostingViewReader: ViewRepresentable where Content: View { 17 | 18 | #if os(macOS) 19 | public typealias PlatformView = NSView 20 | 21 | public func makeNSView(context: Context) -> _HostingViewWrapper { 22 | .init(frame: .zero) 23 | } 24 | 25 | public func updateNSView(_ nsView: _HostingViewWrapper, context: Context) { 26 | nsView.hostingView.rootView = self.contentBuilder(nsView) 27 | } 28 | #else 29 | public typealias PlatformView = UIView 30 | 31 | public func makeUIView(context: Context) -> _HostingViewWrapper { 32 | .init(frame: .zero) 33 | } 34 | 35 | public func updateUIView(_ uiView: _HostingViewWrapper, context: Context) { 36 | uiView.hostingViewController.rootView = self.contentBuilder(uiView) 37 | } 38 | #endif 39 | 40 | private let contentBuilder: (PlatformView) -> Content 41 | 42 | public init(@ViewBuilder content: @escaping (PlatformView) -> Content) { 43 | self.contentBuilder = content 44 | } 45 | 46 | public final class _HostingViewWrapper: PlatformView { 47 | 48 | #if os(macOS) 49 | fileprivate let hostingView: NSHostingView 50 | #else 51 | fileprivate let hostingViewController: UIHostingController 52 | #endif 53 | 54 | override init(frame: CGRect) { 55 | #if os(macOS) 56 | hostingView = .init(rootView: nil) 57 | #else 58 | hostingViewController = .init(rootView: nil) 59 | let hostingView: UIView = hostingViewController.view 60 | hostingView.backgroundColor = .clear 61 | #endif 62 | hostingView.translatesAutoresizingMaskIntoConstraints = false 63 | 64 | super.init(frame: frame) 65 | 66 | addSubview(hostingView) 67 | NSLayoutConstraint.activate([ 68 | hostingView.leadingAnchor.constraint(equalTo: self.leadingAnchor), 69 | hostingView.trailingAnchor.constraint(equalTo: self.trailingAnchor), 70 | hostingView.topAnchor.constraint(equalTo: self.topAnchor), 71 | hostingView.bottomAnchor.constraint(equalTo: self.bottomAnchor), 72 | ]) 73 | } 74 | 75 | required init?(coder: NSCoder) { 76 | fatalError("init(coder:) has not been implemented") 77 | } 78 | 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /Sources/CyanUI/BackgroundMaterial.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/5/31. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(macOS) 7 | 8 | import SwiftUI 9 | 10 | @available(macOS 11.0, *) 11 | public struct BackgroundMaterial: View { 12 | 13 | let blendColor: Color? 14 | 15 | public init(blendColor: Color? = nil) { 16 | self.blendColor = blendColor 17 | } 18 | 19 | public var body: some View { 20 | VisualEffectView( 21 | state: .followsWindowActiveState, 22 | material: .hudWindow, 23 | blendingMode: .behindWindow 24 | ) 25 | .overlay( 26 | blendColor ?? Color.windowBackground 27 | .opacity(0.4) 28 | ) 29 | } 30 | } 31 | 32 | #endif 33 | -------------------------------------------------------------------------------- /Sources/CyanUI/Banner/BannerManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/10/9. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(iOS) || targetEnvironment(macCatalyst) 7 | 8 | import SwiftUI 9 | import UIKit 10 | 11 | @available(iOS 15.0, *) 12 | public class BannerManager { 13 | 14 | class _AnnouncementStack: ObservableObject { 15 | 16 | struct IdentifiedAnnouncement: Identifiable { 17 | var id: UUID 18 | var announcement: AnyPresentableAnnouncement 19 | 20 | init(announcement: A) where A: PresentableAnnouncement { 21 | self.id = UUID() 22 | self.announcement = AnyPresentableAnnouncement(announcement) 23 | } 24 | } 25 | 26 | @Published var announcements: [IdentifiedAnnouncement] = [] 27 | 28 | func push(announcement: A) where A: PresentableAnnouncement { 29 | if !announcements.isEmpty { 30 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(80)) { 31 | let _ = withAnimation(.spring()) { 32 | self.announcements.removeFirst() 33 | } 34 | } 35 | } 36 | announcements.append(.init(announcement: announcement)) 37 | } 38 | 39 | fileprivate func remove(for id: UUID) { 40 | guard let index = announcements.firstIndex(where: { announcement in 41 | announcement.id == id 42 | }) else { return } 43 | announcements.remove(at: index) 44 | } 45 | 46 | } 47 | 48 | public static let shared = BannerManager() 49 | 50 | let announcementStack = _AnnouncementStack() 51 | 52 | public func post(announcement: A) where A: PresentableAnnouncement { 53 | announcementStack.push(announcement: announcement) 54 | } 55 | 56 | public func makeBannerContainerView() -> UIView { 57 | PillContainerHostingView(rootView: PillContainerView(stack: announcementStack, pillEndDisplay: { id in 58 | self.announcementStack.remove(for: id) 59 | })) 60 | } 61 | 62 | } 63 | 64 | @available(iOS 15.0, *) 65 | public protocol PresentableAnnouncement { 66 | 67 | associatedtype Content: View 68 | 69 | @ViewBuilder var content: Self.Content { get } 70 | 71 | } 72 | 73 | @available(iOS 15.0, *) 74 | public struct AnyPresentableAnnouncement: PresentableAnnouncement { 75 | 76 | public typealias Content = AnyView 77 | 78 | private let announcementContent: Self.Content 79 | 80 | public init(_ announcement: A) where A: PresentableAnnouncement { 81 | self.announcementContent = AnyView(announcement.content) 82 | } 83 | 84 | public var content: AnyView { announcementContent } 85 | 86 | } 87 | 88 | @available(iOS 15.0, *) 89 | extension String: PresentableAnnouncement { 90 | 91 | public var content: some View { 92 | Text(self) 93 | .pillBannerContent() 94 | } 95 | 96 | } 97 | 98 | #endif 99 | -------------------------------------------------------------------------------- /Sources/CyanUI/Banner/PillContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/10/9. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(iOS) || targetEnvironment(macCatalyst) 7 | 8 | import UIKit 9 | import SwiftUI 10 | 11 | @available(iOS 15.0, *) 12 | struct PillContainerView: View { 13 | 14 | @ObservedObject private var stack: BannerManager._AnnouncementStack 15 | 16 | private let pillEndDisplay: (UUID) -> Void 17 | 18 | init(stack: BannerManager._AnnouncementStack, pillEndDisplay: @escaping (UUID) -> Void = { _ in }) { 19 | self.stack = stack 20 | self.pillEndDisplay = pillEndDisplay 21 | } 22 | 23 | var body: some View { 24 | GeometryReader { proxy in 25 | ZStack(alignment: .top) { 26 | HStack(spacing: 0) { 27 | Color.clear 28 | } 29 | ForEach(stack.announcements) { item in 30 | PillView(announcement: item.announcement) { 31 | pillEndDisplay(item.id) 32 | } 33 | .transition(.asymmetric(insertion: .identity, 34 | removal: .scale(scale: 0.3).combined(with: .opacity))) 35 | } 36 | } 37 | .padding(.vertical, proxy.safeAreaInsets.top > 12 ? 0 : 12) 38 | } 39 | } 40 | 41 | } 42 | 43 | @available(iOS 15.0, *) 44 | struct PillBannerContentModifier: ViewModifier { 45 | 46 | func body(content: Content) -> some View { 47 | content 48 | .multilineTextAlignment(.center) 49 | .font(.system(size: 13, weight: .bold)) 50 | .padding(.horizontal, 30) 51 | .padding(.vertical, 18) 52 | .frame(minWidth: 180) 53 | .background(Color.secondarySystemGroupedBackground.opacity(0.6)) 54 | .background(.ultraThinMaterial) 55 | .clipShape(Capsule()) 56 | .shadow(color: .black.opacity(0.08), radius: 16, x: 0, y: 4) 57 | .padding(.horizontal, 30) 58 | } 59 | 60 | } 61 | 62 | extension View { 63 | 64 | @available(iOS 15.0, *) 65 | func pillBannerContent() -> some View { 66 | modifier(PillBannerContentModifier()) 67 | } 68 | 69 | } 70 | 71 | @available(iOS 15.0, *) 72 | fileprivate struct PillView: View where A: PresentableAnnouncement { 73 | 74 | private let announcement: A 75 | private let endDisplayAction: () -> Void 76 | 77 | @State private var frame: CGRect = .zero 78 | 79 | @State private var opacity: CGFloat = 0 80 | @State private var slideAnimated: Bool = false 81 | 82 | @State private var willDisappear: Bool = false 83 | 84 | init(announcement: A, endDisplayAction: @escaping () -> Void) { 85 | self.announcement = announcement 86 | self.endDisplayAction = endDisplayAction 87 | } 88 | 89 | var body: some View { 90 | announcement.content 91 | .offset(y: slideAnimated ? 0 : -frame.maxY) 92 | .opacity(opacity) 93 | .overlay { 94 | GeometryReader { proxy in 95 | Color.clear 96 | .onAppear { 97 | frame = proxy.frame(in: .global) 98 | opacity = 1 99 | withAnimation(.spring()) { 100 | slideAnimated.toggle() 101 | } 102 | } 103 | .onChange(of: proxy.frame(in: .global)) { newValue in 104 | if frame.width > newValue.width && !slideAnimated { 105 | opacity = 0 106 | willDisappear = true 107 | } 108 | } 109 | } 110 | } 111 | .onAppear { 112 | DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { 113 | if !willDisappear { 114 | withAnimation(.spring()) { 115 | slideAnimated.toggle() 116 | } 117 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(370)) { 118 | endDisplayAction() 119 | } 120 | } 121 | } 122 | } 123 | } 124 | 125 | } 126 | 127 | #endif 128 | -------------------------------------------------------------------------------- /Sources/CyanUI/Banner/PillContainerViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/10/9. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(iOS) || targetEnvironment(macCatalyst) 7 | 8 | import UIKit 9 | import SwiftUI 10 | 11 | @available(iOS 15.0, *) 12 | class PillContainerHostingView: UIView { 13 | 14 | private let hostingView: _UIHostingView 15 | 16 | init(rootView: PillContainerView) { 17 | hostingView = _UIHostingView(rootView: rootView) 18 | hostingView.backgroundColor = .clear 19 | 20 | super.init(frame: .zero) 21 | 22 | addSubview(hostingView) 23 | hostingView.translatesAutoresizingMaskIntoConstraints = false 24 | NSLayoutConstraint.activate([ 25 | hostingView.leadingAnchor.constraint(equalTo: self.leadingAnchor), 26 | hostingView.trailingAnchor.constraint(equalTo: self.trailingAnchor), 27 | hostingView.topAnchor.constraint(equalTo: self.topAnchor), 28 | hostingView.bottomAnchor.constraint(equalTo: self.bottomAnchor), 29 | ]) 30 | } 31 | 32 | required init?(coder: NSCoder) { 33 | fatalError("init(coder:) has not been implemented") 34 | } 35 | 36 | override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 37 | let view = super.hitTest(point, with: event) 38 | return (view === self || view === hostingView) ? nil : view 39 | } 40 | 41 | } 42 | 43 | #endif 44 | -------------------------------------------------------------------------------- /Sources/CyanUI/BeautifulButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2021/11/19. 3 | // Copyright (c) 2021 ktiays. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | import CyanSwiftUI 8 | import CyanExtensions 9 | 10 | public struct BeautifulButton: View { 11 | 12 | public let text: String 13 | public let action: () -> () 14 | 15 | public enum Style { 16 | case fill 17 | case bordered 18 | } 19 | 20 | private let style: Style 21 | private let cornerRadius: CGFloat 22 | 23 | @State private var pressed: Bool = false 24 | @State private var buttonSize: CGSize = .zero 25 | 26 | public init(text: String, action: @escaping () -> (), style: Style = .fill, cornerRadius: CGFloat = 12) { 27 | self.text = text 28 | self.action = action 29 | self.style = style 30 | self.cornerRadius = cornerRadius 31 | } 32 | 33 | private let systemBlueColor: Color = .init(platformColor: .systemBlue) 34 | 35 | private var textColor: Color { 36 | switch style { 37 | case .fill: 38 | return .white 39 | case .bordered: 40 | return systemBlueColor.opacity(pressed ? 0.3 : 1) 41 | } 42 | } 43 | 44 | private var backgroundColor: Color { 45 | switch style { 46 | case .fill: 47 | return systemBlueColor 48 | case .bordered: 49 | return textColor 50 | } 51 | } 52 | 53 | private var scaleRatio: CGFloat { 54 | switch style { 55 | case .fill: 56 | return pressed ? 0.9 : 1.0 57 | case .bordered: 58 | return 1.0 59 | } 60 | } 61 | 62 | public var body: some View { 63 | Text(text) 64 | .fontWeight(.medium) 65 | .foregroundColor(textColor) 66 | .padding(.horizontal, 18) 67 | .padding(.vertical, 10) 68 | .background( 69 | Group { 70 | switch style { 71 | case .fill: 72 | backgroundColor 73 | case .bordered: 74 | RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) 75 | .stroke(lineWidth: 2) 76 | .foregroundColor(backgroundColor) 77 | } 78 | } 79 | ) 80 | .animatedClickable(configuration: .empty( 81 | activeAnimation: .spring(response: 0.2), 82 | identityAnimation: .spring(response: 0.35) 83 | ).combined(with: { active in 84 | AnyViewModifier { 85 | $0.overlay(Color(white: 0, opacity: active && style == .fill ? 0.3 : 0)) 86 | .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) 87 | } 88 | }).activeScale(0.9), action: action) 89 | } 90 | 91 | public func buttonStyle(_ style: Style) -> BeautifulButton { 92 | .init(text: text, action: action, style: style, cornerRadius: cornerRadius) 93 | } 94 | 95 | public func cornerRadius(_ radius: CGFloat) -> BeautifulButton { 96 | .init(text: text, action: action, style: style, cornerRadius: radius) 97 | } 98 | 99 | } 100 | 101 | public struct RoundedBorderedButtonStyle: ButtonStyle { 102 | 103 | private let lineWidth: CGFloat 104 | private let cornerRadius: CGFloat 105 | private let borderColor: Color 106 | 107 | public init(lineWidth: CGFloat = 1, cornerRadius: CGFloat = 12, borderColor: Color = Color(platformColor: .label)) { 108 | self.lineWidth = lineWidth 109 | self.cornerRadius = cornerRadius 110 | self.borderColor = borderColor 111 | } 112 | 113 | public func makeBody(configuration: Configuration) -> some View { 114 | configuration.label 115 | .padding(.horizontal, 18) 116 | .padding(.vertical, 10) 117 | .overlay( 118 | RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) 119 | .stroke(lineWidth: lineWidth) 120 | .foregroundColor(borderColor) 121 | ) 122 | .opacity(configuration.isPressed ? 0.3 : 1) 123 | } 124 | 125 | } 126 | 127 | public extension ButtonStyle where Self == RoundedBorderedButtonStyle { 128 | 129 | static var roundedBordered: RoundedBorderedButtonStyle { .init() } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /Sources/CyanUI/Duotone.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/1/13. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | import CyanExtensions 8 | 9 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) 10 | public protocol DuotoneIconStyle { 11 | 12 | typealias Configuration = DuotoneIconConfiguration 13 | 14 | func makePrimaryShape(configuration: Self.Configuration) 15 | 16 | func makeSecondaryShape(configuration: Self.Configuration) 17 | 18 | } 19 | 20 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) 21 | public struct DuotoneIconConfiguration { 22 | public let context: GraphicsContext 23 | public let bounds: CGRect 24 | public let isHighlighted: Bool 25 | } 26 | 27 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) 28 | public struct IconColorConfiguration { 29 | public let primaryColor: Color 30 | public let highlightPrimaryColor: Color 31 | public let secondaryColor: Color 32 | public let highlightSecondaryColor: Color 33 | 34 | public init(primaryColor: Color, highlightPrimaryColor: Color, secondaryColor: Color, highlightSecondaryColor: Color) { 35 | self.primaryColor = primaryColor 36 | self.highlightPrimaryColor = highlightPrimaryColor 37 | self.secondaryColor = secondaryColor 38 | self.highlightSecondaryColor = highlightSecondaryColor 39 | } 40 | 41 | public init(primaryColor: PlatformColor, highlightPrimaryColor: PlatformColor, secondaryColor: PlatformColor, highlightSecondaryColor: PlatformColor) { 42 | self.init(primaryColor: .init(platformColor: primaryColor), 43 | highlightPrimaryColor: .init(platformColor: highlightPrimaryColor), 44 | secondaryColor: .init(platformColor: secondaryColor), 45 | highlightSecondaryColor: .init(platformColor: highlightSecondaryColor)) 46 | } 47 | } 48 | 49 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) 50 | public struct IconColorConfigurationKey: EnvironmentKey { 51 | public static var defaultValue: IconColorConfiguration? { 52 | let primaryColor: Color = .init(lightColor: .label, darkColor: .white) 53 | let secondaryColor: Color = .white.opacity(0.5) 54 | return .init(primaryColor: primaryColor, highlightPrimaryColor: primaryColor, 55 | secondaryColor: secondaryColor, highlightSecondaryColor: secondaryColor) 56 | } 57 | } 58 | 59 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) 60 | public extension EnvironmentValues { 61 | var iconColorConfiguration: IconColorConfiguration? { 62 | get { self[IconColorConfigurationKey.self] } 63 | set { self[IconColorConfigurationKey.self] = newValue } 64 | } 65 | } 66 | 67 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) 68 | public struct DuotoneIcon: View where S: DuotoneIconStyle { 69 | 70 | @Environment(\.iconColorConfiguration) var iconColorConfiguration 71 | 72 | private let style: S 73 | 74 | private let isHighlighted: Bool 75 | 76 | public init(_ style: S, isHighlighted: Bool) { 77 | self.style = style 78 | self.isHighlighted = isHighlighted 79 | } 80 | 81 | public var body: some View { 82 | Canvas { context, size in 83 | context.drawLayer { context in 84 | if let secondaryColor = iconColorConfiguration?.secondaryColor, 85 | let hightlightSecondaryColor = iconColorConfiguration?.highlightSecondaryColor { 86 | context.fill(.init(.init(origin: .zero, size: size)), with: .color(isHighlighted ? hightlightSecondaryColor : secondaryColor)) 87 | context.blendMode = .destinationIn 88 | } 89 | context.drawLayer { context in 90 | style.makeSecondaryShape(configuration: .init(context: context, bounds: .init(origin: .zero, size: size), isHighlighted: isHighlighted)) 91 | } 92 | } 93 | context.drawLayer { context in 94 | if let primaryColor = iconColorConfiguration?.primaryColor, 95 | let hightlightPrimaryColor = iconColorConfiguration?.highlightPrimaryColor { 96 | context.fill(.init(.init(origin: .zero, size: size)), with: .color(isHighlighted ? hightlightPrimaryColor : primaryColor)) 97 | context.blendMode = .destinationIn 98 | } 99 | context.drawLayer { context in 100 | style.makePrimaryShape(configuration: .init(context: context, bounds: .init(origin: .zero, size: size), isHighlighted: isHighlighted)) 101 | } 102 | } 103 | } 104 | .aspectRatio(1, contentMode: .fit) 105 | } 106 | 107 | public func multicolor() -> some View { 108 | environment(\.iconColorConfiguration, nil) 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /Sources/CyanUI/HexView/DataProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/2/10. 3 | // Copyright © 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | /// An abstraction for data-reading tasks that eliminates the need 9 | /// to manage a raw memory buffer in the hex view. 10 | public protocol HexViewDataProvider { 11 | 12 | /// An integer that indicates the length of the underlying data. 13 | var length: Int { get } 14 | 15 | /// Returns a byte at the given index. 16 | func byte(at index: Int) -> UInt8 17 | 18 | } 19 | 20 | /// A simple data provider implementation that use `Data` as backing store. 21 | public struct HexViewDirectDataProvider: HexViewDataProvider { 22 | 23 | public let data: Data 24 | 25 | public init?(contentsOf url: URL) { 26 | guard let data = try? Data(contentsOf: url) else { 27 | return nil 28 | } 29 | self.init(data: data) 30 | } 31 | 32 | public init(data: Data) { 33 | self.data = data 34 | } 35 | 36 | public var length: Int { 37 | return data.count 38 | } 39 | 40 | public func byte(at index: Int) -> UInt8 { 41 | return data[index] 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Sources/CyanUI/HexView/DrawingHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/2/10. 3 | // Copyright © 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | #if os(macOS) 7 | 8 | import Cocoa 9 | import CoreGraphics 10 | 11 | class HexViewDrawingHelper { 12 | 13 | enum ComponentType { 14 | case hex 15 | case ascii 16 | 17 | var stride: Int { 18 | switch self { 19 | case .hex: 20 | return 2 21 | case .ascii: 22 | return 1 23 | } 24 | } 25 | } 26 | 27 | struct Position: Comparable, Equatable { 28 | let line: Int 29 | let column: Int 30 | 31 | static func < (lhs: HexViewDrawingHelper.Position, rhs: HexViewDrawingHelper.Position) -> Bool { 32 | if lhs.line < rhs.line { 33 | return true 34 | } 35 | if lhs.line == rhs.line { 36 | return lhs.column < rhs.column 37 | } 38 | return false 39 | } 40 | } 41 | 42 | var font: NSFont? { 43 | didSet { 44 | recalculateMetrics() 45 | prepareCaches() 46 | } 47 | } 48 | 49 | var selectionFillColor: NSColor? 50 | var selectionStrokeColor: NSColor? 51 | 52 | var octetsPerLine = 16 53 | var gutterWidth: CGFloat = 80 54 | var lineGap: CGFloat = 10 { 55 | didSet { 56 | recalculateMetrics() 57 | } 58 | } 59 | 60 | private(set) var charHeight: CGFloat = 0 61 | private(set) var lineHeight: CGFloat = 0 62 | private(set) var charDrawingOriginY: CGFloat = 0 63 | private(set) var charWidth: CGFloat = 0 64 | 65 | private var octetImageCache = [CGImage]() 66 | private var asciiImageCache = [CGImage]() 67 | 68 | private func recalculateMetrics() { 69 | guard let font = self.font else { 70 | return 71 | } 72 | 73 | charHeight = font.capHeight 74 | lineHeight = charHeight + lineGap 75 | charDrawingOriginY = font.ascender - font.capHeight 76 | 77 | // Assume that only monospaced fonts are set, each character will 78 | // has the same width. 79 | let sampleString = "0" as NSString 80 | let boundingRect = sampleString.boundingRect( 81 | with: .zero, options: [], attributes: [.font: font] 82 | ) 83 | charWidth = boundingRect.width 84 | } 85 | 86 | private func prepareCaches() { 87 | let colorSpace = CGColorSpaceCreateDeviceRGB() 88 | let scale = NSScreen.main?.backingScaleFactor ?? 2 // TODO: what if the main screen changed? 89 | let charVerticalAdjust = font!.descender + (lineHeight - charHeight) / 2 90 | 91 | func createCGImage(with string: String, color: NSColor? = nil) -> CGImage { 92 | let width = Int(ceil(charWidth * CGFloat(string.count)) * scale) 93 | let height = Int(ceil(lineHeight) * scale) 94 | let context = CGContext(data: nil, width: width, height: height, 95 | bitsPerComponent: 8, bytesPerRow: 4 * width, space: colorSpace, 96 | bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! 97 | context.scaleBy(x: scale, y: scale) 98 | 99 | // Become current context for Cocoa string drawing. 100 | NSGraphicsContext.current = .init(cgContext: context, flipped: false) 101 | 102 | (string as NSString).draw(at: .init(x: 0, y: charVerticalAdjust), withAttributes: [ 103 | .font: self.font!, 104 | .foregroundColor: color ?? NSColor.textColor, 105 | ]) 106 | 107 | let image = context.makeImage()! 108 | 109 | // Resign the current context. 110 | NSGraphicsContext.current = nil 111 | 112 | return image 113 | } 114 | 115 | let specialAsciiImage = createCGImage(with: ".", color: NSColor.textColor.withAlphaComponent(0.3)) 116 | for i in 0...255 { 117 | octetImageCache.append(createCGImage(with: String(hexStringWith: i, uppercase: true, paddingTo: 2))) 118 | 119 | if i < 32 || i >= 127 { 120 | // These characters are invisible. 121 | asciiImageCache.append(specialAsciiImage) 122 | } else { 123 | asciiImageCache.append(createCGImage(with: String(Character(UnicodeScalar(UInt8(i)))))) 124 | } 125 | 126 | } 127 | } 128 | 129 | func drawSelection(from startCharacterOrigin: CGPoint, 130 | to endCharacterOrigin: CGPoint, 131 | stride: CGFloat, 132 | drawingBounds: CGRect, 133 | in context: CGContext) { 134 | let selectionPath = NSBezierPath() 135 | selectionPath.move(to: startCharacterOrigin) 136 | if abs(startCharacterOrigin.y - endCharacterOrigin.y) < CGFloat.ulpOfOne { 137 | // One line. 138 | selectionPath.line(to: .init(x: endCharacterOrigin.x + charWidth * stride, y: endCharacterOrigin.y)) 139 | selectionPath.line(to: .init(x: endCharacterOrigin.x + charWidth * stride, 140 | y: endCharacterOrigin.y + lineHeight)) 141 | selectionPath.line(to: .init(x: startCharacterOrigin.x, y: startCharacterOrigin.y + lineHeight)) 142 | } else { 143 | selectionPath.line(to: .init(x: drawingBounds.maxX, y: startCharacterOrigin.y)) 144 | selectionPath.line(to: .init(x: drawingBounds.maxX, y: endCharacterOrigin.y)) 145 | selectionPath.line(to: .init(x: endCharacterOrigin.x + charWidth * stride, 146 | y: endCharacterOrigin.y)) 147 | selectionPath.line(to: .init(x: endCharacterOrigin.x + charWidth * stride, 148 | y: endCharacterOrigin.y + lineHeight)) 149 | selectionPath.line(to: .init(x: drawingBounds.minX, y: endCharacterOrigin.y + lineHeight)) 150 | selectionPath.line(to: .init(x: drawingBounds.minX, y: startCharacterOrigin.y + lineHeight)) 151 | selectionPath.line(to: .init(x: startCharacterOrigin.x, y: startCharacterOrigin.y + lineHeight)) 152 | } 153 | selectionPath.close() 154 | 155 | if let selectionStrokeColor = self.selectionStrokeColor { 156 | context.setStrokeColor(selectionStrokeColor.cgColor) 157 | } 158 | if let selectionFillColor = selectionFillColor { 159 | context.setFillColor(selectionFillColor.cgColor) 160 | } 161 | 162 | selectionPath.fill() 163 | selectionPath.stroke() 164 | } 165 | 166 | func dataIndex(at position: Position) -> Int { 167 | return position.line * octetsPerLine + position.column 168 | } 169 | 170 | func origin(of component: ComponentType, at position: Position) -> CGPoint { 171 | let line = position.line 172 | let column = position.column 173 | 174 | let y = CGFloat(line) * lineHeight 175 | switch component { 176 | case .hex: 177 | return .init(x: gutterWidth + charWidth * CGFloat(column) * 3, y: y) 178 | case .ascii: 179 | let startX = gutterWidth + charWidth * (CGFloat(octetsPerLine) * 3 + 2) 180 | return .init(x: startX + charWidth * CGFloat(column), y: y) 181 | } 182 | } 183 | 184 | func hitTest(at point: CGPoint) -> (ComponentType, Position)? { 185 | let pointX = point.x 186 | let line = Int(floor(point.y / lineHeight)) 187 | 188 | // Anatomy of a line: 189 | // | GUTTER | OCTETS | ASCII CHARS | 190 | // 191 | // We use the scanning fashion to determine the hit component. 192 | 193 | if pointX <= gutterWidth { 194 | return (.hex, .init(line: line, column: 0)) 195 | } 196 | 197 | // Octets area: 198 | var startX = gutterWidth 199 | let endX = origin(of: .ascii, at: .init(line: 0, column: 0)).x 200 | if pointX < endX { 201 | let column = min(max(Int(floor((pointX - startX) / (charWidth * 3))), 0), octetsPerLine - 1) 202 | return (.hex, .init(line: line, column: column)) 203 | } 204 | 205 | // Ascii characters area: 206 | startX = endX 207 | let column = min(max(Int(floor((pointX - startX) / charWidth)), 0), octetsPerLine - 1) 208 | return (.ascii, .init(line: line, column: column)) 209 | } 210 | 211 | @inline(__always) func octetImage(of byte: UInt8) -> CGImage { 212 | return octetImageCache[Int(byte)] 213 | } 214 | 215 | @inline(__always) func asciiImage(of byte: UInt8) -> CGImage { 216 | return asciiImageCache[Int(byte)] 217 | } 218 | 219 | } 220 | 221 | class _HexViewComponentLineLayer: CALayer { 222 | 223 | let drawingHelper: HexViewDrawingHelper 224 | var representingLine: Int = -1 225 | 226 | private var hexComponents = [_HexViewComponentLayer]() 227 | private var asciiComponents = [_HexViewComponentLayer]() 228 | 229 | init(drawingHelper: HexViewDrawingHelper) { 230 | self.drawingHelper = drawingHelper 231 | super.init() 232 | commomInit() 233 | } 234 | 235 | required init?(coder: NSCoder) { 236 | fatalError("Should not call this initializer") 237 | } 238 | 239 | private func commomInit() { 240 | let charWidth = drawingHelper.charWidth 241 | let lineHeight = drawingHelper.lineHeight 242 | 243 | // Populate components. 244 | var currentComponentWidth = charWidth * 2 245 | for i in 0.. end { 281 | hexComponent.contents = nil 282 | asciiComponent.contents = nil 283 | } else { 284 | let byte = dataProvider.byte(at: dataIndex) 285 | hexComponent.contents = drawingHelper.octetImage(of: byte) 286 | asciiComponent.contents = drawingHelper.asciiImage(of: byte) 287 | } 288 | } 289 | } 290 | 291 | } 292 | 293 | fileprivate class _HexViewComponentLayer: CALayer { 294 | 295 | override func action(forKey event: String) -> CAAction? { 296 | return NSNull() 297 | } 298 | 299 | } 300 | 301 | #endif 302 | -------------------------------------------------------------------------------- /Sources/CyanUI/HexView/HexView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/2/10. 3 | // Copyright © 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | #if os(macOS) 7 | 8 | import Cocoa 9 | 10 | /// A set of optional methods that hex view delegates can use to 11 | /// manage selection, and more. 12 | @objc public protocol HexViewDelegate: NSObjectProtocol { 13 | 14 | /// Tells the delegate when the selection has changed in the hex view. 15 | @objc optional func selectionDidChangeInHexView(_ view: HexView) 16 | 17 | } 18 | 19 | /// A view that allows inspecting large data in hex view. 20 | public class HexView: NSView { 21 | 22 | public override var isFlipped: Bool { 23 | return true 24 | } 25 | 26 | private var viewportBounds: NSRect { 27 | guard let clipView = enclosingScrollView?.contentView else { 28 | return bounds 29 | } 30 | return clipView.bounds.insetBy(dx: 0, dy: -64) 31 | } 32 | 33 | private var totalLines: Int { 34 | return Int(ceil(Double((dataProvider?.length ?? 0)) / Double(drawingHelper.octetsPerLine))) 35 | } 36 | 37 | private var drawingHelper = HexViewDrawingHelper() 38 | private weak var cursorBlinkTimer: Timer? 39 | private var lineLayers = [_HexViewComponentLineLayer]() 40 | private var recyclePool = [_HexViewComponentLineLayer]() 41 | 42 | private var visualSelectionStart: HexViewDrawingHelper.Position? 43 | private var visualSelectionEnd: HexViewDrawingHelper.Position? 44 | private var componentUnderCursor: HexViewDrawingHelper.ComponentType = .hex 45 | private var isMouseDragging = false 46 | private var cursorBlinkState = false 47 | 48 | /// The data provider of the receiver’s content. 49 | public var dataProvider: HexViewDataProvider? { 50 | didSet { 51 | reloadData() 52 | } 53 | } 54 | 55 | /// The receiver’s delegate. 56 | public weak var delegate: HexViewDelegate? 57 | 58 | /// The range of bytes selected in the receiver. 59 | public var selectedRange: NSRange? { 60 | // No selections and inserting points. 61 | guard let visualSelectionEnd = visualSelectionEnd else { 62 | return nil 63 | } 64 | 65 | // Has inserting points but no selections. 66 | var dataSelectionEnd = drawingHelper.dataIndex(at: visualSelectionEnd) 67 | guard let visualSelectionStart = visualSelectionStart else { 68 | return .init(location: dataSelectionEnd, length: 1) 69 | } 70 | 71 | // Has selections. 72 | let selectionEnd = max(visualSelectionEnd, visualSelectionStart) 73 | let selectionStart = min(visualSelectionEnd, visualSelectionStart) 74 | dataSelectionEnd = drawingHelper.dataIndex(at: selectionEnd) 75 | let dataSelectionStart = drawingHelper.dataIndex(at: selectionStart) 76 | return .init(location: dataSelectionStart, length: dataSelectionEnd - dataSelectionStart + 1) 77 | } 78 | 79 | deinit { 80 | cursorBlinkTimer?.invalidate() 81 | } 82 | 83 | override init(frame frameRect: NSRect) { 84 | super.init(frame: frameRect) 85 | commonInit() 86 | } 87 | 88 | required init?(coder: NSCoder) { 89 | super.init(coder: coder) 90 | commonInit() 91 | } 92 | 93 | private func commonInit() { 94 | wantsLayer = true 95 | 96 | drawingHelper.font = .monospacedSystemFont(ofSize: 12, weight: .regular) 97 | drawingHelper.selectionFillColor = NSColor.selectedTextBackgroundColor.withAlphaComponent(0.4) 98 | drawingHelper.selectionStrokeColor = NSColor.selectedTextBackgroundColor 99 | 100 | NotificationCenter.default.addObserver(self, 101 | selector: #selector(handleViewportChange(_:)), 102 | name: NSView.frameDidChangeNotification, 103 | object: nil) 104 | NotificationCenter.default.addObserver(self, 105 | selector: #selector(handleViewportChange(_:)), 106 | name: NSView.boundsDidChangeNotification, 107 | object: nil) 108 | } 109 | 110 | public override func viewDidMoveToSuperview() { 111 | super.viewDidMoveToSuperview() 112 | 113 | enclosingScrollView?.scroll(.zero) 114 | } 115 | 116 | public override func layout() { 117 | super.layout() 118 | 119 | let newFrame = CGRect( 120 | x: 0, y: 0, 121 | width: superview!.frame.width, height: ceil(CGFloat(totalLines) * drawingHelper.lineHeight) 122 | ) 123 | frame = newFrame 124 | } 125 | 126 | public override func draw(_ dirtyRect: NSRect) { 127 | super.draw(dirtyRect) 128 | 129 | guard let context = NSGraphicsContext.current?.cgContext else { 130 | return 131 | } 132 | 133 | // Gather drawing informations. 134 | let lineHeight = drawingHelper.lineHeight 135 | let charWidth = drawingHelper.charWidth 136 | 137 | let octetsPerLine = drawingHelper.octetsPerLine 138 | 139 | let viewportBounds = self.viewportBounds 140 | let lowerBounds = max(Int(floor(viewportBounds.minY / lineHeight)), 0) 141 | let upperBounds = min(Int(ceil(viewportBounds.maxY / lineHeight)), totalLines) 142 | 143 | // Draw alternative backgrounds. 144 | context.setFillColor(NSColor.textColor.withAlphaComponent(0.04).cgColor) 145 | for i in lowerBounds.. toLine { 292 | lastLine.removeFromSuperlayer() 293 | lineLayers.removeLast() 294 | recyclePool.append(lastLine) 295 | } else { 296 | break 297 | } 298 | } 299 | 300 | let lineHeight = drawingHelper.lineHeight 301 | let viewportBounds = self.viewportBounds 302 | 303 | func addLineLayer(for line: Int, forwards: Bool = true) { 304 | let lineLayer = recyclePool.isEmpty 305 | ? _HexViewComponentLineLayer(drawingHelper: drawingHelper) 306 | : recyclePool.removeLast() 307 | 308 | lineLayer.representingLine = line 309 | lineLayer.loadData(from: dataProvider!) 310 | if forwards { 311 | lineLayers.append(lineLayer) 312 | } else { 313 | lineLayers.insert(lineLayer, at: 0) 314 | } 315 | layer?.addSublayer(lineLayer) 316 | } 317 | 318 | func layoutLine(_ line: _HexViewComponentLineLayer) { 319 | line.frame = .init(x: 0, y: lineHeight * CGFloat(line.representingLine), 320 | width: viewportBounds.width, height: lineHeight) 321 | } 322 | 323 | // Then, append additional lines if needed. 324 | // Forwards: 325 | while true { 326 | let lastLine = lineLayers.last?.representingLine ?? (fromLine - 1) 327 | if lastLine >= toLine { 328 | break 329 | } 330 | addLineLayer(for: lastLine + 1) 331 | } 332 | // Backwards: 333 | while true { 334 | let firstLine = lineLayers.first?.representingLine ?? (toLine + 1) 335 | if firstLine <= fromLine { 336 | break 337 | } 338 | addLineLayer(for: firstLine - 1, forwards: false) 339 | } 340 | 341 | // All lines are added to the view, now layout them. 342 | lineLayers.forEach { layoutLine($0) } 343 | } 344 | 345 | private func clampCursorPosition(_ position: HexViewDrawingHelper.Position) -> HexViewDrawingHelper.Position { 346 | let line = min(max(position.line, 0), totalLines - 1) 347 | let column = min(max(position.column, 0), drawingHelper.octetsPerLine - 1) 348 | return .init(line: line, column: column) 349 | } 350 | 351 | private func startCursorBlinking() { 352 | if cursorBlinkTimer == nil { 353 | cursorBlinkTimer = .scheduledTimer(withTimeInterval: 0.6, repeats: true) { [weak self] _ in 354 | guard let self = self else { return } 355 | self.cursorBlinkState.toggle() 356 | self.setNeedsDisplay(self.viewportBounds) 357 | } 358 | } 359 | } 360 | 361 | } 362 | 363 | #endif 364 | -------------------------------------------------------------------------------- /Sources/CyanUI/PopupButton/PopupButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/1/15. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(macOS) 7 | 8 | import SwiftUI 9 | import AppKit 10 | import ObjectiveC 11 | import Combine 12 | import CyanExtensions 13 | 14 | @available(macOS 12.0, *) 15 | public struct PopupButton: View where S: StringProtocol { 16 | 17 | public let contents: [S] 18 | 19 | @Binding public var selection: S? 20 | 21 | private let backgroundColor: Color 22 | 23 | public init(contents: [S], selection: Binding) { 24 | self.init(contents: contents, selection: selection, backgroundColor: .secondary) 25 | } 26 | 27 | private init(contents: [S], selection: Binding, backgroundColor: Color) { 28 | self.contents = contents 29 | _selection = selection 30 | self.backgroundColor = backgroundColor 31 | } 32 | 33 | public var body: some View { 34 | HStack { 35 | Text(selection ?? "") 36 | .padding(.trailing, 12) 37 | Spacer() 38 | // Draw a rounded corner triangle. 39 | CGSize(width: 25, height: 25) |> { size in 40 | CGSize(width: 8, height: 4) |> { triangleSize in 41 | Path { path in 42 | let origin = CGPoint(x: (size.width - triangleSize.width) / 2, y: (size.height - triangleSize.height) / 2) 43 | path.move(to: origin) 44 | path.addLine(to: .init(x: origin.x + triangleSize.width, y: origin.y)) 45 | path.addLine(to: .init(x: origin.x + triangleSize.width / 2, y: origin.y + triangleSize.height)) 46 | path.closeSubpath() 47 | } |> { trianglePath in 48 | Color(nsColor: .init(lightColor: .init(red: 30.0 / 255, green: 30.0 / 255, blue: 30.0 / 255, alpha: 1), 49 | darkColor: .init(red: 212.0 / 255, green: 212.0 / 255, blue: 212.0 / 255, alpha: 1))) |> { foregroundColor in 50 | ZStack { 51 | trianglePath.fill(foregroundColor) 52 | trianglePath.stroke(foregroundColor, style: .init(lineWidth: 3, lineCap: .round, lineJoin: .round)) 53 | } 54 | .aspectRatio(1, contentMode: .fit) 55 | .frame(width: size.width) 56 | } 57 | } 58 | } 59 | } 60 | } 61 | .padding(.leading, 10) 62 | .padding(.trailing, 4) 63 | .padding(.vertical, 5) 64 | .background(backgroundColor) 65 | .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) 66 | .overlay(_PopupButtonTrigger(contents: contents, selection: $selection)) 67 | } 68 | 69 | public func backgroundColor(_ color: Color) -> PopupButton { 70 | .init(contents: self.contents, selection: _selection, backgroundColor: color) 71 | } 72 | 73 | } 74 | 75 | // MARK: - Trigger 76 | 77 | @available(macOS 12.0, *) 78 | struct _PopupButtonTrigger: NSViewRepresentable where S: StringProtocol { 79 | 80 | let contents: [S] 81 | @Binding var selection: S? 82 | 83 | func makeNSView(context: Context) -> _PopupButtonTriggerView { 84 | .init(selection: $selection) 85 | } 86 | 87 | func updateNSView(_ nsView: _PopupButtonTriggerView, context: Context) { 88 | nsView.popupListContent = contents 89 | } 90 | 91 | } 92 | 93 | @available(macOS 12.0, *) 94 | class _PopupButtonTriggerView: NSView where S: StringProtocol { 95 | 96 | /// The contents that acts as the data source of the pop-up menu. 97 | var popupListContent: [S] = [] { 98 | didSet { 99 | reloadContent() 100 | } 101 | } 102 | @Binding private var selection: S? 103 | 104 | private var currentSelectedIndex: Int? { 105 | if let selection = selection { 106 | return popupListContent.firstIndex(of: selection) 107 | } 108 | return nil 109 | } 110 | 111 | private var localMonitor: Any? 112 | private var globalMonitor: Any? 113 | 114 | private weak var popupWindow: NSWindow? 115 | private var isPresented: Bool { popupWindow != nil } 116 | 117 | private let mouseUpSubject: PassthroughSubject = .init() 118 | 119 | init(selection: Binding) { 120 | _selection = selection 121 | super.init(frame: .zero) 122 | 123 | localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseUp, .leftMouseDown]) { [weak self] event in 124 | self?.handleMouseEvent(event) 125 | return event 126 | } 127 | globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseUp, .leftMouseDown]) { [weak self] event in 128 | self?.handleMouseEvent(event) 129 | } 130 | } 131 | 132 | /// The position where the mouse click when the menu pops up. 133 | private var presentedPoint: NSPoint? 134 | 135 | private func handleMouseEvent(_ event: NSEvent) { 136 | switch event.type { 137 | case .leftMouseDown: 138 | if let window = popupWindow { 139 | let mouseLocation = window.convertPoint(toScreen: event.locationInWindow) 140 | if !window.frame.contains(mouseLocation) { 141 | removePopupWindow() 142 | } 143 | } 144 | case .leftMouseUp: 145 | if popupWindow == nil { return } 146 | let currentMouseLocation = NSEvent.mouseLocation 147 | if let presentedPoint = presentedPoint { 148 | if !presentedPoint.nearBy(currentMouseLocation) { 149 | removePopupWindow() 150 | } else { return } 151 | } else { 152 | removePopupWindow() 153 | } 154 | mouseUpSubject.send(currentMouseLocation) 155 | default: break 156 | } 157 | } 158 | 159 | required init?(coder: NSCoder) { 160 | fatalError("init(coder:) has not been implemented") 161 | } 162 | 163 | deinit { 164 | if let localMonitor = localMonitor { 165 | NSEvent.removeMonitor(localMonitor) 166 | } 167 | if let globalMonitor = globalMonitor { 168 | NSEvent.removeMonitor(globalMonitor) 169 | } 170 | } 171 | 172 | private let menuPadding: CGFloat = 16 173 | private let menuInnerPadding: CGFloat = 4 174 | private let menuItemHeight: CGFloat = 28 175 | 176 | override func mouseDown(with event: NSEvent) { 177 | super.mouseDown(with: event) 178 | 179 | if isPresented { return } 180 | 181 | let window = NSWindow(contentViewController: NSHostingController(rootView: _PopupList(contents: popupListContent, selection: _selection, mouseUpEventPublisher: mouseUpSubject.eraseToAnyPublisher()))) 182 | window.isReleasedWhenClosed = false 183 | window.styleMask = .borderless 184 | window.hasShadow = false 185 | window.backgroundColor = .clear 186 | self.window?.addChildWindow(window, ordered: .above) 187 | 188 | updateWindow(window) 189 | 190 | popupWindow = window 191 | 192 | // Save the location of the click that triggered the menu popup. 193 | presentedPoint = NSEvent.mouseLocation 194 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(280)) { [weak self] in 195 | self?.presentedPoint = nil 196 | } 197 | } 198 | 199 | private func removePopupWindow() { 200 | if let window = popupWindow { 201 | window.ignoresMouseEvents = true 202 | NSAnimationContext.runAnimationGroup { context in 203 | context.duration = 0.4 204 | window.animator().alphaValue = 0 205 | popupWindow = nil 206 | } completionHandler: { 207 | window.close() 208 | } 209 | } 210 | } 211 | 212 | private func updateWindow(_ window: NSWindow) { 213 | window.contentView?.layout() 214 | let contentSize = window.contentView?.fittingSize ?? .zero 215 | 216 | if let frameFromWindow = self.window?.convertToScreen(convert(frame, to: nil)) { 217 | window.setFrame(.init(x: frameFromWindow.minX - menuPadding - menuInnerPadding, 218 | y: frameFromWindow.minY + frameFromWindow.height / 2 - menuPadding - menuInnerPadding - menuItemHeight * (max(popupListContent.count, 1) - (currentSelectedIndex ?? 0) - 0.5), 219 | width: max(contentSize.width, bounds.width + (menuPadding + menuInnerPadding) * 2), 220 | height: contentSize.height), 221 | display: true) 222 | } 223 | } 224 | 225 | private func reloadContent() { 226 | guard let window = popupWindow else { 227 | return 228 | } 229 | if let controller = window.contentViewController as? NSHostingController<_PopupList> { 230 | controller.rootView = .init( 231 | contents: popupListContent, 232 | selection: _selection, 233 | mouseUpEventPublisher: mouseUpSubject.eraseToAnyPublisher() 234 | ) 235 | updateWindow(window) 236 | } 237 | } 238 | 239 | } 240 | 241 | #endif 242 | -------------------------------------------------------------------------------- /Sources/CyanUI/PopupButton/PopupList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/1/16. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(macOS) 7 | 8 | import SwiftUI 9 | import Combine 10 | import CyanExtensions 11 | 12 | @available(macOS 12.0, *) 13 | struct _PopupList: View where Content: StringProtocol { 14 | 15 | let contents: [Content] 16 | @Binding var selection: Content? 17 | 18 | @State private var highlighted: Content? 19 | @State private var isAnimatedIn = false 20 | 21 | private let mouseUpEventPublisher: AnyPublisher 22 | 23 | init(contents: [Content], selection: Binding, mouseUpEventPublisher: AnyPublisher) { 24 | _selection = selection 25 | self.mouseUpEventPublisher = mouseUpEventPublisher 26 | 27 | // TODO: Optimize the handling of duplicated items. 28 | self.contents = contents.enumerated().filter { index, element in 29 | contents.firstIndex(of: element) == index 30 | }.map { $0.element } 31 | } 32 | 33 | var body: some View { 34 | VStack(spacing: 0) { 35 | if contents.isEmpty { 36 | Rectangle() 37 | .foregroundColor(.clear) 38 | .frame(height: 28) 39 | } 40 | ForEach(contents, id: \.self) { item in 41 | ZStack { 42 | Color(nsColor: .labelColor) 43 | .opacity(highlighted == item ? 0.04 : 0) 44 | .cornerRadius(8) 45 | HStack { 46 | Circle() 47 | .frame(width: 6) 48 | .foregroundColor(selection == item ? Color(nsColor: .labelColor) : .clear) 49 | Text(item) 50 | .fontWeight(.medium) 51 | Spacer() 52 | } 53 | .padding(.leading, 10) 54 | .padding(.trailing, 36) 55 | .padding(.vertical, 6) 56 | } 57 | .overlay { 58 | _PopupItem(tag: item, 59 | highlighted: $highlighted, 60 | selection: $selection, 61 | mouseUpEventPublisher: mouseUpEventPublisher) 62 | } 63 | } 64 | } 65 | .padding(4) 66 | .ignoresSafeArea() 67 | .background { 68 | VisualEffectView(material: .titlebar) 69 | .overlay { 70 | Color(lightColor: .white, darkColor: .black).opacity(0.3) 71 | } 72 | } 73 | .cornerRadius(12) 74 | .shadow(color: .black.opacity(0.12), radius: 10) 75 | .padding(16) 76 | .scaleEffect(isAnimatedIn ? 1 : 0.5, anchor: .center) 77 | .opacity(isAnimatedIn ? 1 : 0) 78 | .onAppear { 79 | withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { 80 | isAnimatedIn = true 81 | } 82 | } 83 | } 84 | 85 | } 86 | 87 | struct _PopupItem: NSViewRepresentable where S: StringProtocol { 88 | 89 | let tag: S 90 | @Binding var highlighted: S? 91 | @Binding var selection: S? 92 | 93 | let mouseUpEventPublisher: AnyPublisher 94 | @State private var cancellable: AnyCancellable? 95 | 96 | func makeNSView(context: Context) -> _PopupItemView { 97 | let view: _PopupItemView = .init(tag: tag, highlighted: $highlighted, selection: $selection) 98 | DispatchQueue.main.async { 99 | cancellable = mouseUpEventPublisher.sink { 100 | view.updateSelection(with: $0) 101 | } 102 | } 103 | return view 104 | } 105 | 106 | func updateNSView(_ nsView: _PopupItemView, context: Context) { } 107 | 108 | } 109 | 110 | final class _PopupItemView: NSView where S: StringProtocol { 111 | 112 | @Binding private var highlighted: S? 113 | @Binding private var selection: S? 114 | 115 | private let itemTag: S 116 | 117 | init(tag: S, highlighted: Binding, selection: Binding) { 118 | self.itemTag = tag 119 | _highlighted = highlighted 120 | _selection = selection 121 | super.init(frame: .zero) 122 | } 123 | 124 | required init?(coder: NSCoder) { 125 | fatalError("init(coder:) has not been implemented") 126 | } 127 | 128 | private var trackingArea: NSTrackingArea? 129 | 130 | override func layout() { 131 | super.layout() 132 | 133 | if let trackingArea = trackingArea { 134 | removeTrackingArea(trackingArea) 135 | } 136 | trackingArea = NSTrackingArea(rect: bounds, options: [.activeAlways, .mouseEnteredAndExited, .enabledDuringMouseDrag], owner: self, userInfo: nil) 137 | addTrackingArea(trackingArea!) 138 | } 139 | 140 | override func mouseEntered(with event: NSEvent) { 141 | super.mouseEntered(with: event) 142 | 143 | highlighted = itemTag 144 | } 145 | 146 | override func mouseExited(with event: NSEvent) { 147 | super.mouseExited(with: event) 148 | 149 | if itemTag == highlighted { 150 | highlighted = nil 151 | } 152 | } 153 | 154 | /// Updates the selected item of the menu according to the position of the mouse click. 155 | /// - Parameter mousePoint: The position where the mouse event is triggered. 156 | func updateSelection(with mousePoint: NSPoint) { 157 | if window?.convertToScreen(convert(frame, to: nil)).contains(mousePoint) == true { 158 | selection = itemTag 159 | } 160 | } 161 | 162 | } 163 | 164 | #endif 165 | -------------------------------------------------------------------------------- /Sources/CyanUI/SegmentedControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2021/5/2. 3 | // Copyright (c) 2021 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(iOS) || targetEnvironment(macCatalyst) 7 | 8 | import SwiftUI 9 | import CyanExtensions 10 | 11 | fileprivate let textColor = PlatformColor(lightColor: #colorLiteral(red: 0.368627451, green: 0.3843137255, blue: 0.4470588235, alpha: 1), darkColor: #colorLiteral(red: 0.5098039216, green: 0.5098039216, blue: 0.5333333333, alpha: 1)) 12 | fileprivate let selectedTextColor = #colorLiteral(red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0) 13 | fileprivate let defaultSelectedBackgroundColor = PlatformColor.systemBlue // #colorLiteral(red: 0.1411764706, green: 0.4196078431, blue: 0.9921568627, alpha: 1) 14 | 15 | public struct SegmentedControl: View where Content: RandomAccessCollection, Content.Element: SegmentedControlItem, SelectionValue == Content.Element.ID { 16 | 17 | private struct _FramePreference: PreferenceKey { 18 | 19 | typealias Value = [SelectionValue : Anchor] 20 | 21 | static var defaultValue: [SelectionValue : Anchor] { [:] } 22 | 23 | static func reduce(value: inout [SelectionValue : Anchor], nextValue: () -> [SelectionValue : Anchor]) { 24 | for pair in nextValue() { 25 | value[pair.key] = pair.value 26 | } 27 | } 28 | 29 | } 30 | 31 | let selection: Binding 32 | let content: Content 33 | var scrollable: Bool 34 | 35 | @Environment(\.selectedBackgroundColor) var selectedBackgroundColor: Color? 36 | 37 | public init(selection: Binding, content: Content, scrollable: Bool = false) { 38 | self.content = content 39 | self.selection = selection 40 | self.scrollable = scrollable 41 | } 42 | 43 | private func _backgroundView(with anchorInfo: _FramePreference.Value, color: Color = .black) -> some View { 44 | var selectedFrame: Anchor! 45 | for pair in anchorInfo { 46 | if pair.key == selection.wrappedValue { 47 | selectedFrame = pair.value 48 | } 49 | } 50 | 51 | return GeometryReader { proxy in 52 | Capsule() 53 | .size(width: proxy[selectedFrame].width, height: proxy[selectedFrame].height) 54 | .fill(color) 55 | .offset(x: proxy[selectedFrame].minX, y: 0) 56 | } 57 | } 58 | 59 | @State private var scrollToID: ((SelectionValue) -> Void)? = nil 60 | 61 | private var contentView: some View { 62 | HStack { 63 | HStack { 64 | ForEach(content) { item in 65 | Button(action: { 66 | withAnimation(.interpolatingSpring(stiffness: 300.0, damping: 30)) { 67 | selection.wrappedValue = item.id 68 | } 69 | }, label: { 70 | Text(item.text) 71 | .font(.system(size: 14, weight: .semibold)) 72 | .foregroundColor(Color(textColor)) 73 | .frame(height: 24) 74 | .padding(.horizontal, 16) 75 | .padding(.vertical, 4) 76 | .background( 77 | Color.clear 78 | .anchorPreference(key: _FramePreference.self, 79 | value: .bounds) { [item.id: $0] } 80 | ) 81 | }) 82 | .buttonStyle(BorderlessButtonStyle()) 83 | .id(item.id) 84 | } 85 | } 86 | .onChange(of: selection.wrappedValue, perform: { value in 87 | withAnimation(.spring()) { 88 | scrollToID?(value) 89 | } 90 | }) 91 | .overlayPreferenceValue(_FramePreference.self) { value in 92 | _backgroundView(with: value, color: Color(selectedTextColor)) 93 | .blendMode(.sourceAtop) 94 | } 95 | .compositingGroup() 96 | .backgroundPreferenceValue(_FramePreference.self) { value in 97 | _backgroundView(with: value, color: selectedBackgroundColor ?? Color(defaultSelectedBackgroundColor)) 98 | } 99 | .padding(.horizontal) 100 | if !scrollable { Spacer() } 101 | } 102 | } 103 | 104 | public var body: some View { 105 | if scrollable { 106 | ScrollViewReader { proxy in 107 | ScrollView(scrollable ? .horizontal : [], showsIndicators: false) { 108 | contentView 109 | } 110 | .onAppear { 111 | scrollToID = { value in 112 | proxy.scrollTo(value) 113 | } 114 | } 115 | } 116 | } else { 117 | contentView 118 | } 119 | } 120 | 121 | } 122 | 123 | public protocol SegmentedControlItem: Identifiable { 124 | 125 | associatedtype ID = Hashable & Equatable 126 | 127 | var text: String { get } 128 | 129 | } 130 | 131 | public extension EnvironmentValues { 132 | var selectedBackgroundColor: Color? { 133 | get { self[_SelectedBackgroundColorEnvironmentKey.self] } 134 | set { self[_SelectedBackgroundColorEnvironmentKey.self] = newValue } 135 | } 136 | } 137 | 138 | public extension View { 139 | 140 | func selectedBackgroundColor(_ color: Color?) -> some View { 141 | return environment(\.selectedBackgroundColor, color) 142 | } 143 | 144 | } 145 | 146 | fileprivate struct _SelectedBackgroundColorEnvironmentKey: EnvironmentKey { 147 | static var defaultValue: Color? { nil } 148 | } 149 | 150 | // MARK: - Preview 151 | 152 | struct SegmentedControl_Previews: PreviewProvider { 153 | 154 | struct Item: SegmentedControlItem { 155 | 156 | typealias ID = Int 157 | 158 | var id: Int 159 | var text: String 160 | 161 | } 162 | 163 | private struct PreviewView: View { 164 | 165 | @State var selection: Int = 0 166 | 167 | let content: [Item] = [ 168 | Item(id: 0, text: "Overview"), 169 | Item(id: 1, text: "Productivity"), 170 | Item(id: 2, text: "Cart"), 171 | ] 172 | 173 | var body: some View { 174 | SegmentedControl(selection: $selection, content: content, scrollable: true) 175 | .selectedBackgroundColor(Color(.orange)) 176 | } 177 | 178 | } 179 | 180 | static var previews: some View { 181 | PreviewView() 182 | } 183 | } 184 | 185 | #endif 186 | -------------------------------------------------------------------------------- /Sources/CyanUI/VisualEffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/1/15. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(macOS) 7 | 8 | import SwiftUI 9 | 10 | @available(macOS 11.0, *) 11 | public struct VisualEffectView: NSViewRepresentable { 12 | 13 | public typealias BlendingMode = NSVisualEffectView.BlendingMode 14 | public typealias Material = NSVisualEffectView.Material 15 | public typealias State = NSVisualEffectView.State 16 | 17 | public let state: State 18 | public let blendingMode: BlendingMode 19 | public let material: Material 20 | 21 | public init(state: State = .active, material: Material = .contentBackground, blendingMode: BlendingMode = .behindWindow) { 22 | self.state = state 23 | self.material = material 24 | self.blendingMode = blendingMode 25 | } 26 | 27 | public func makeNSView(context: Context) -> NSVisualEffectView { .init() } 28 | 29 | public func updateNSView(_ nsView: NSVisualEffectView, context: Context) { 30 | nsView.state = state 31 | nsView.blendingMode = blendingMode 32 | nsView.material = material 33 | } 34 | 35 | } 36 | 37 | #endif 38 | -------------------------------------------------------------------------------- /Sources/CyanUtils/AnyError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/10/17. 3 | // Copyright (c) 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public class AnyError: NSError { 9 | 10 | public let message: String? 11 | 12 | public init(message: String, code: Int = -1, domain: String? = nil) { 13 | self.message = message 14 | super.init(domain: domain ?? "", code: code, userInfo: [ 15 | NSLocalizedDescriptionKey: message 16 | ]) 17 | } 18 | 19 | public required init?(coder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /Sources/CyanUtils/ArrayBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2023/8/13. 3 | // Copyright (c) 2023 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | @resultBuilder 9 | public struct ArrayBuilder { 10 | public static func buildBlock(_ components: Element...) -> [Element] { 11 | components 12 | } 13 | 14 | public static func buildBlock(_ componentGroups: [Element]...) -> [Element] { 15 | componentGroups.flatMap { $0 } 16 | } 17 | 18 | public static func buildEither(first component: [Element]) -> [Element] { 19 | component 20 | } 21 | 22 | public static func buildEither(second component: [Element]) -> [Element] { 23 | component 24 | } 25 | 26 | public static func buildOptional(_ component: [Element]?) -> [Element] { 27 | component ?? [] 28 | } 29 | 30 | public static func buildArray(_ components: [[Element]]) -> [Element] { 31 | components.flatMap { $0 } 32 | } 33 | 34 | public static func buildExpression(_ expression: Element) -> [Element] { 35 | [expression] 36 | } 37 | 38 | public static func buildLimitedAvailability(_ component: [Element]) -> [Element] { 39 | component 40 | } 41 | 42 | public static func buildFinalResult(_ component: [Element]) -> [Element] { 43 | component 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/CyanUtils/ChildProcess.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/10/17. 3 | // Copyright (c) 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | #if os(macOS) 9 | @MainActor 10 | public final class ChildProcess { 11 | 12 | public struct Builder { 13 | 14 | public let executablePath: String 15 | public var arguments = [String]() 16 | public var environment = [String : String]() 17 | 18 | public init(executablePath: String) { 19 | self.executablePath = executablePath 20 | } 21 | 22 | public func addArgument(_ argument: String) -> Builder { 23 | var newBuilder = self 24 | newBuilder.arguments.append(argument) 25 | return newBuilder 26 | } 27 | 28 | public func addEnvironmentVariable(_ value: String, forKey key: String) -> Builder { 29 | var newBuilder = self 30 | newBuilder.environment[key] = value 31 | return newBuilder 32 | } 33 | 34 | } 35 | 36 | public let executablePath: String 37 | public let arguments: [String] 38 | public let environment: [String : String] 39 | 40 | public var stdoutStream: AsyncStream? { 41 | if _stdoutStream == nil { 42 | attachStdoutStream() 43 | } 44 | 45 | return _stdoutStream 46 | } 47 | 48 | private(set) var isRunning = false 49 | private(set) var pid: pid_t? 50 | 51 | private var stdinFileDescriptor: Int32? 52 | private var stdoutFileDescriptor: Int32? 53 | private var stdoutFDRefCount = 0 { 54 | didSet { 55 | if stdoutFDRefCount == 0 { 56 | let _ = stdoutFileDescriptor.map { close($0) } 57 | stdoutFileDescriptor = nil 58 | } 59 | } 60 | } 61 | private var exitContinuations = [CheckedContinuation]() 62 | private var processSource: DispatchSourceProcess? 63 | 64 | private var _stdoutStream: AsyncStream? 65 | private var readSource: DispatchSourceRead? 66 | 67 | deinit { 68 | // No-op 69 | } 70 | 71 | public init(builder: Builder) { 72 | self.executablePath = builder.executablePath 73 | self.arguments = builder.arguments 74 | self.environment = builder.environment 75 | } 76 | 77 | public func start() throws { 78 | guard !isRunning else { 79 | return 80 | } 81 | 82 | // Setup the file actions. 83 | var fileActions: posix_spawn_file_actions_t! 84 | var errorCode = posix_spawn_file_actions_init(&fileActions) 85 | guard errorCode == 0 else { 86 | throw AnyError(message: "posix_spawn_file_actions_init failed with error code: \(errorCode)") 87 | } 88 | defer { posix_spawn_file_actions_destroy(&fileActions) } 89 | 90 | let stdinPipeFds = try Self.createPipe() 91 | let stdoutPipeFds = try Self.createPipe() 92 | defer { 93 | close(stdinPipeFds.0) 94 | close(stdoutPipeFds.1) 95 | } 96 | 97 | posix_spawn_file_actions_adddup2(&fileActions, stdinPipeFds.0, STDIN_FILENO) 98 | posix_spawn_file_actions_addclose(&fileActions, stdinPipeFds.0) 99 | posix_spawn_file_actions_adddup2(&fileActions, stdoutPipeFds.1, STDOUT_FILENO) 100 | posix_spawn_file_actions_addclose(&fileActions, stdoutPipeFds.1) 101 | 102 | // Convert strings for C APIs. 103 | let argv = Self.makeCStringArray(forStrings: [executablePath] + arguments) 104 | defer { argv.forEach { $0.map { free(.init($0)) } } } 105 | 106 | let envp = Self.makeCStringArray(forStrings: environment.map { "\($0.key)=\($0.value)" }) 107 | defer { envp.forEach { $0.map { free(.init($0)) } } } 108 | 109 | // Start the process! 110 | var pid: pid_t! = -1 111 | errorCode = argv.withUnsafeBufferPointer { argvPointer in 112 | return envp.withUnsafeBufferPointer { envpPointer in 113 | return posix_spawn(&pid, executablePath, &fileActions, nil, 114 | argvPointer.baseAddress, envpPointer.baseAddress) 115 | } 116 | } 117 | 118 | guard errorCode == 0 else { 119 | throw AnyError(message: "posix_spawn failed with error code: \(errorCode)") 120 | } 121 | 122 | // Listen for child-process events. 123 | let processSource = DispatchSource.makeProcessSource(identifier: pid, 124 | eventMask: [.exit, .signal], 125 | queue: .main) 126 | processSource.setEventHandler(handler: .init { 127 | self.handleProcessEvent() 128 | }) 129 | processSource.resume() 130 | self.processSource = processSource 131 | 132 | stdinFileDescriptor = stdinPipeFds.1 133 | stdoutFileDescriptor = stdoutPipeFds.0 134 | stdoutFDRefCount = 1 135 | self.pid = pid 136 | isRunning = true 137 | } 138 | 139 | public func waitUntilExit() async throws -> Int { 140 | guard isRunning else { 141 | throw AnyError(message: "Child process is not running") 142 | } 143 | 144 | return try await withCheckedThrowingContinuation { cont in 145 | exitContinuations.append(cont) 146 | } 147 | } 148 | 149 | private func handleProcessEvent() { 150 | guard isRunning else { 151 | return 152 | } 153 | 154 | guard let data = processSource?.data, let pid = self.pid else { 155 | fatalError("Inconsistent internal state") 156 | } 157 | 158 | var wstatus: Int32 = 0 159 | switch data { 160 | case .exit, .signal: 161 | var errorCode: pid_t = EINTR 162 | while errorCode == EINTR { 163 | errorCode = waitpid(pid, &wstatus, WNOHANG) 164 | if errorCode == pid { 165 | break 166 | } else if errorCode == EAGAIN { 167 | return 168 | } 169 | } 170 | 171 | default: 172 | return 173 | } 174 | 175 | // Handle exit or signal. 176 | let _wstatus = wstatus & 0x7f 177 | if _wstatus != _WSTOPPED && _wstatus != 0 { 178 | // The process is killed by signal. 179 | let error = AnyError(message: "Process was killed by signal \(_wstatus)") 180 | for cont in exitContinuations { 181 | cont.resume(throwing: error) 182 | } 183 | } else { 184 | for cont in exitContinuations { 185 | cont.resume(returning: Int(wstatus >> 8)) 186 | } 187 | } 188 | 189 | // Perform clean-ups. 190 | isRunning = false 191 | let _ = stdinFileDescriptor.map { close($0) } 192 | stdinFileDescriptor = nil 193 | stdoutFDRefCount -= 1 194 | self.pid = nil 195 | processSource?.cancel() 196 | processSource = nil 197 | } 198 | 199 | private func attachStdoutStream() { 200 | guard let stdoutFileDescriptor = self.stdoutFileDescriptor else { 201 | return 202 | } 203 | 204 | _stdoutStream = AsyncStream(Data.self, bufferingPolicy: .unbounded) { cont in 205 | self.stdoutFDRefCount += 1 206 | let source = DispatchSource.makeReadSource( 207 | fileDescriptor: stdoutFileDescriptor, 208 | queue: .main) 209 | source.setEventHandler(handler: .init { 210 | var data = Data() 211 | defer { 212 | if !data.isEmpty { 213 | cont.yield(data) 214 | } 215 | } 216 | while true { 217 | let bufferSize = 1024 218 | var buffer = Data(count: bufferSize) 219 | let readCount = buffer.withUnsafeMutableBytes { pointer in 220 | return read(stdoutFileDescriptor, pointer.baseAddress, bufferSize) 221 | } 222 | 223 | // Handle end-of-file or interruption. 224 | if readCount <= 0 { 225 | if errno == EINTR { 226 | continue 227 | } else if errno == EAGAIN { 228 | if !self.isRunning { 229 | // No data available and the process is terminated, 230 | // there will not be data produced anymore. 231 | cont.finish() 232 | } 233 | } else { 234 | // Other error occurred, just close the stream. 235 | cont.finish() 236 | } 237 | return 238 | } 239 | 240 | data.append(buffer.subdata(in: 0.. (Int32, Int32) { 263 | var fds: [Int32] = [0, 0] 264 | var errorCode = fds.withUnsafeMutableBufferPointer { pointer in 265 | return pipe(pointer.baseAddress) 266 | } 267 | guard errorCode == 0 else { 268 | throw AnyError(message: "failed to create pipe with error code: \(errorCode)") 269 | } 270 | 271 | // Make the reading-end of the pipe non-blocking, because we will 272 | // use kevent to poll the data asynchrously. 273 | errorCode = fcntl(fds[0], F_SETFL, O_NONBLOCK) 274 | guard errorCode == 0 else { 275 | throw AnyError(message: "failed to make fd non-blocking with error code: \(errorCode)") 276 | } 277 | 278 | return (fds[0], fds[1]) 279 | } 280 | 281 | private static func makeCStringArray(forStrings strings: [String]) -> [UnsafeMutablePointer?] { 282 | var array = [UnsafeMutablePointer?]() 283 | for string in strings { 284 | string.withCString { 285 | array.append(strdup($0)) 286 | } 287 | } 288 | array.append(nil) 289 | return array 290 | } 291 | 292 | } 293 | 294 | extension ChildProcess: Sendable { } 295 | #endif 296 | -------------------------------------------------------------------------------- /Sources/CyanUtils/Defaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2021/6/18. 3 | // Copyright (c) 2021 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | // MARK: Property Wrapper for UserDefaults 9 | 10 | public protocol ConstructibleFromDefaults { 11 | static func from(_ defaults: UserDefaults, with key: String) -> Self? 12 | } 13 | 14 | public protocol DefaultsWriting { 15 | func write(to defaults: UserDefaults, with key: String) 16 | } 17 | 18 | private func getPrimitiveDefaultsValue(of type: T.Type, 19 | from defaults: UserDefaults, 20 | with key: String, 21 | objCType: String) -> T? { 22 | guard let number = defaults.object(forKey: key) as? NSNumber else { 23 | return nil 24 | } 25 | let actualObjCType = String(cString: number.objCType) 26 | guard objCType == actualObjCType else { 27 | return nil 28 | } 29 | return number as? T 30 | } 31 | 32 | extension Int: ConstructibleFromDefaults { 33 | public static func from(_ defaults: UserDefaults, with key: String) -> Self? { 34 | return getPrimitiveDefaultsValue(of: Int.self, from: defaults, with: key, objCType: "q") 35 | } 36 | } 37 | 38 | extension Float: ConstructibleFromDefaults { 39 | public static func from(_ defaults: UserDefaults, with key: String) -> Float? { 40 | return getPrimitiveDefaultsValue(of: Float.self, from: defaults, with: key, objCType: "f") 41 | } 42 | } 43 | 44 | extension Double: ConstructibleFromDefaults { 45 | public static func from(_ defaults: UserDefaults, with key: String) -> Double? { 46 | return getPrimitiveDefaultsValue(of: Double.self, from: defaults, with: key, objCType: "d") 47 | } 48 | } 49 | 50 | extension String: ConstructibleFromDefaults { 51 | public static func from(_ defaults: UserDefaults, with key: String) -> Self? { 52 | defaults.string(forKey: key) 53 | } 54 | } 55 | 56 | extension Bool: ConstructibleFromDefaults { 57 | public static func from(_ defaults: UserDefaults, with key: String) -> Self? { 58 | return getPrimitiveDefaultsValue(of: Bool.self, from: defaults, with: key, objCType: "c") 59 | } 60 | } 61 | 62 | extension Array: ConstructibleFromDefaults where Element: ConstructibleFromDefaults { 63 | public static func from(_ defaults: UserDefaults, with key: String) -> Array? { 64 | defaults.array(forKey: key) as? Self 65 | } 66 | } 67 | 68 | extension URL: ConstructibleFromDefaults { 69 | public static func from(_ defaults: UserDefaults, with key: String) -> URL? { 70 | defaults.url(forKey: key) 71 | } 72 | } 73 | 74 | extension Dictionary: ConstructibleFromDefaults where Key == String, Value: ConstructibleFromDefaults { 75 | public static func from(_ defaults: UserDefaults, with key: String) -> Dictionary? { 76 | defaults.dictionary(forKey: key) as? Self 77 | } 78 | } 79 | 80 | extension Data: ConstructibleFromDefaults { 81 | public static func from(_ defaults: UserDefaults, with key: String) -> Data? { 82 | defaults.data(forKey: key) 83 | } 84 | } 85 | 86 | @propertyWrapper 87 | public struct Defaults where T: ConstructibleFromDefaults { 88 | 89 | public let key: String 90 | public var defaultValue: T { 91 | `default`() 92 | } 93 | private let `default`: () -> T 94 | 95 | @available(*, deprecated, renamed: "init(key:defaultValue:)", message: "Use init(key:defaultValue:) instead") 96 | public init(key: String, default: @escaping () -> T) { 97 | self.key = key 98 | self.default = `default` 99 | } 100 | 101 | public init(key: String, defaultValue: @autoclosure @escaping () -> T) { 102 | self.key = key 103 | self.default = defaultValue 104 | } 105 | 106 | public var wrappedValue: T { 107 | get { 108 | .from(.standard, with: key) ?? defaultValue 109 | } 110 | 111 | nonmutating set { 112 | let userDefaults = UserDefaults.standard 113 | if let customWriting = newValue as? DefaultsWriting { 114 | customWriting.write(to: userDefaults, with: key) 115 | } else { 116 | userDefaults.set(newValue, forKey: key) 117 | } 118 | } 119 | } 120 | 121 | } 122 | 123 | public protocol DefaultsCompatible { 124 | static func defaultValue() -> Self 125 | } 126 | 127 | extension Int: DefaultsCompatible { 128 | public static func defaultValue() -> Self { 0 } 129 | } 130 | 131 | extension Float: DefaultsCompatible { 132 | public static func defaultValue() -> Self { 0 } 133 | } 134 | 135 | extension Double: DefaultsCompatible { 136 | public static func defaultValue() -> Self { 0 } 137 | } 138 | 139 | extension Bool: DefaultsCompatible { 140 | public static func defaultValue() -> Self { false } 141 | } 142 | 143 | extension Defaults where T: DefaultsCompatible { 144 | public init(key: String) { 145 | self.init(key: key, defaultValue: T.defaultValue()) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Sources/CyanUtils/MacrotaskQueue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/10/20. 3 | // Copyright (c) 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | import CCyanUtils 7 | 8 | public class MacrotaskQueue { 9 | 10 | public static let main: MacrotaskQueue = MacrotaskQueue(queue: CAKMacrotaskQueueGetMain()) 11 | 12 | private let queue: OpaquePointer 13 | 14 | internal init(queue: OpaquePointer) { 15 | self.queue = queue 16 | } 17 | 18 | public func addTask(_ task: @convention(block) @escaping () -> Void) { 19 | CAKMacrotaskQueueAddTaskWithHandler(queue, task) 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Sources/CyanUtils/Version.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2024/9/5. 3 | // Copyright (c) 2024 Helixform. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public struct Version: Codable, CustomStringConvertible { 9 | 10 | public let major: Int 11 | public let minor: Int 12 | public let patch: Int 13 | 14 | public enum DecodeError: String, Error { 15 | case invalidFormat = "Invalid format" 16 | } 17 | 18 | public init(from decoder: any Decoder) throws { 19 | let container = try decoder.singleValueContainer() 20 | let text = try container.decode(String.self) 21 | let sequence = text.split(separator: ".") 22 | let count = sequence.count 23 | if count > 3 { 24 | throw DecodeError.invalidFormat 25 | } 26 | 27 | func atoi(_ text: S) throws -> Int where S: StringProtocol { 28 | if let value = Int(text), value >= 0 { 29 | return value 30 | } 31 | throw DecodeError.invalidFormat 32 | } 33 | 34 | var major = 0, minor = 0, patch = 0 35 | if count >= 1 { 36 | major = try atoi(sequence[0]) 37 | } 38 | if count >= 2 { 39 | minor = try atoi(sequence[1]) 40 | } 41 | if count >= 3 { 42 | patch = try atoi(sequence[2]) 43 | } 44 | 45 | self.major = major 46 | self.minor = minor 47 | self.patch = patch 48 | } 49 | 50 | public func encode(to encoder: any Encoder) throws { 51 | var container = encoder.singleValueContainer() 52 | try container.encode(description) 53 | } 54 | 55 | public var description: String { 56 | "\(major).\(minor).\(patch)" 57 | } 58 | } 59 | 60 | extension Version: Comparable { 61 | public static func < (lhs: Version, rhs: Version) -> Bool { 62 | return lhs.major < rhs.major || (lhs.major == rhs.major && lhs.minor < rhs.minor) || (lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch < rhs.patch) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/CyanUtils/WeakPropertyWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2022/1/16. 3 | // Copyright (c) 2022 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | @propertyWrapper public class Weak where T: AnyObject { 9 | 10 | public weak var wrappedValue: T? 11 | 12 | public init(wrappedValue: T?) { 13 | self.wrappedValue = wrappedValue 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /Tests/CyanConcurrencyTests/ExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/10/9. 3 | // Copyright (c) 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | @testable import CyanConcurrency 8 | 9 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, *) 10 | fileprivate func fakeAsyncTransformer(_ input: Int) async -> String { 11 | try? await Task.sleep(for: .milliseconds(10)) 12 | return "\(input)" 13 | } 14 | 15 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, *) 16 | fileprivate func fakeAsyncTransformerThrows(_ input: Int) async throws -> String { 17 | if input % 2 == 0 { 18 | struct _DummyError: Error { } 19 | throw _DummyError() 20 | } 21 | return await fakeAsyncTransformer(input) 22 | } 23 | 24 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, *) 25 | final class ConcurrencyAlgorithmsTests: XCTestCase { 26 | 27 | func testArrayMap() async { 28 | // Test normal use case: 29 | let input = [1, 2, 3, 4] 30 | let result = await input.mapAsync(fakeAsyncTransformer) 31 | XCTAssertEqual(result, ["1", "2", "3", "4"]) 32 | 33 | // Test empty optimization: 34 | let result2 = await [Int]().mapAsync(fakeAsyncTransformer) 35 | XCTAssertEqual(result2, []) 36 | 37 | // Test error handling for transformer errors: 38 | do { 39 | let _ = try await input.mapAsync(fakeAsyncTransformerThrows) 40 | XCTFail("Should not reach here.") 41 | } catch { 42 | // Expected path. 43 | } 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /Tests/CyanUtilsTests/ChildProcessTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2022/10/17. 3 | // Copyright (c) 2022 Cyandev. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | @testable import CyanUtils 8 | 9 | #if os(macOS) 10 | @MainActor 11 | final class ChildProcessTests: XCTestCase { 12 | 13 | func testSimpleExecution() async throws { 14 | let childProcess = ChildProcess(builder: 15 | .init(executablePath: "/bin/sleep") 16 | .addArgument("1")) 17 | try childProcess.start() 18 | 19 | let pid = Int(childProcess.pid ?? 0) 20 | XCTAssertGreaterThan(pid, 0) 21 | 22 | let exitCode = try await childProcess.waitUntilExit() 23 | XCTAssertEqual(exitCode, 0) 24 | } 25 | 26 | func testReadingStdout() async throws { 27 | let childProcess = ChildProcess(builder: 28 | .init(executablePath: "/usr/bin/uname") 29 | .addArgument("-a")) 30 | try childProcess.start() 31 | 32 | let pid = Int(childProcess.pid ?? 0) 33 | XCTAssertGreaterThan(pid, 0) 34 | 35 | let readTask = Task { 36 | guard let stdoutStream = childProcess.stdoutStream else { 37 | throw AnyError(message: "No stdout stream available") 38 | } 39 | var fullData = Data() 40 | for await stdoutData in stdoutStream { 41 | print("Read \(stdoutData.count) bytes from stdout") 42 | fullData.append(stdoutData) 43 | } 44 | return String(data: fullData, encoding: .utf8) 45 | } 46 | 47 | let exitCode = try await childProcess.waitUntilExit() 48 | XCTAssertEqual(exitCode, 0) 49 | 50 | guard let result = try await readTask.value else { 51 | XCTFail("Unexpected stdout data") 52 | return 53 | } 54 | XCTAssertTrue(result.contains("Darwin") && result.contains("xnu")) 55 | } 56 | 57 | @available(macOS 13.0, *) 58 | func testLongTermProcess() async throws { 59 | let childProcess = ChildProcess(builder: 60 | .init(executablePath: "/usr/bin/yes")) 61 | try childProcess.start() 62 | 63 | let pid = Int(childProcess.pid ?? 0) 64 | XCTAssertGreaterThan(pid, 0) 65 | 66 | // Because the program will not exit unless user kills it, we will 67 | // send a `SIGTERM` to it after 1 second. 68 | Task { 69 | try await Task.sleep(for: .seconds(1)) 70 | kill(Int32(pid), SIGTERM) 71 | } 72 | 73 | guard let stdoutStream = childProcess.stdoutStream else { 74 | XCTFail("No stdout stream available") 75 | return 76 | } 77 | 78 | do { 79 | let exitCode = try await childProcess.waitUntilExit() 80 | XCTAssertEqual(exitCode, 0) 81 | } catch { 82 | // It's ok to get some signals that killed the process. 83 | } 84 | 85 | var totalReadCount = 0 86 | for await stdoutData in stdoutStream { 87 | totalReadCount += stdoutData.count 88 | } 89 | XCTAssertGreaterThan(totalReadCount, 0) 90 | } 91 | 92 | } 93 | #endif 94 | --------------------------------------------------------------------------------