├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE.md
├── MenuBuilder.xcworkspace
├── contents.xcworkspacedata
└── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Package.swift
├── README.md
├── Sources
├── MenuBuilder Demo
│ ├── MenuBuilder Demo.xcodeproj
│ │ ├── project.pbxproj
│ │ └── project.xcworkspace
│ │ │ ├── contents.xcworkspacedata
│ │ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ └── MenuBuilder Demo
│ │ ├── AppDelegate.swift
│ │ ├── Assets.xcassets
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ └── Contents.json
│ │ └── Contents.json
│ │ ├── Base.lproj
│ │ └── Main.storyboard
│ │ ├── Info.plist
│ │ ├── MenuBuilder_Demo.entitlements
│ │ └── ViewController.swift
└── MenuBuilder
│ ├── AnyMenuItem.swift
│ ├── CustomMenuItem.swift
│ ├── Documentation.docc
│ ├── Documentation.md
│ └── Extensions
│ │ └── AnyMenuItem.md
│ ├── IndentGroup.swift
│ ├── MenuBuilder.swift
│ ├── MenuInvoker.swift
│ ├── MenuItem.swift
│ ├── MenuItemView.swift
│ └── SeparatorItem.swift
└── Tests
├── LinuxMain.swift
└── MenuBuilderTests
├── MenuBuilderTests.swift
└── XCTestManifests.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | *.doccarchive
7 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020-2021 Jed Fox
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MenuBuilder.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/MenuBuilder.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.5
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "MenuBuilder",
7 | // only tested on macOS 11, but I’m happy to accept PRs for older versions
8 | platforms: [.macOS(.v10_10)],
9 | products: [
10 | .library(
11 | name: "MenuBuilder",
12 | targets: ["MenuBuilder"]),
13 | ],
14 | dependencies: [],
15 | targets: [
16 | .target(
17 | name: "MenuBuilder",
18 | dependencies: []),
19 | .testTarget(
20 | name: "MenuBuilderTests",
21 | dependencies: ["MenuBuilder"]),
22 | ]
23 | )
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MenuBuilder
2 |
3 | A function builder for `NSMenu`s, similar in spirit to SwiftUI’s `ViewBuilder`.
4 |
5 | Usage example (see demo or [read the documentation](https://menubuilder.jedfox.com) for more details):
6 |
7 | ```swift
8 | let menu = NSMenu {
9 | MenuItem("Click me")
10 | .onSelect { print("clicked!") }
11 | MenuItem("Item with a view")
12 | .view {
13 | MyMenuItemView() // any SwiftUI view
14 | }
15 | SeparatorItem()
16 | MenuItem("About") {
17 | // rendered as disabled items in a submenu
18 | MenuItem("Version 1.2.3")
19 | MenuItem("Copyright 2021")
20 | }
21 | MenuItem("Quit")
22 | .shortcut("q")
23 | .onSelect { NSApp.terminate(nil) }
24 | }
25 |
26 | // later, to replace the menu items with different/updated ones:
27 | menu.replaceItems {
28 | MenuItem("Replaced item").onSelect { print("Hello!") }
29 | }
30 | ```
31 |
32 | Note that there is no way to preserve the existing menu items, although it should be possible to implement that — feel free to open an issue or PR adding update support if you want it!
33 |
34 | ## Changelog
35 |
36 | ### v3.0.0
37 |
38 | * (Potentially **BREAKING**) When your app has a deployment target of macOS 12 or newer, passing a string literal to `MenuItem("Title Here")` will now create a `String.LocalizationValue`, which will make it easier to localize your app if you use MenuBuilder.
39 | * If your app is not localized (or your deployment target is macOS 11 or older), you shouldn’t notice any changes.
40 | * If your app is localized, review your UI that uses MenuBuilder and make sure that you’re not getting any unexpected translations. You can migrate to `MenuItem(verbatim: "Title Here")` if you want to opt out of the new behavior.
41 | * Thanks to @ShikiSuen for getting this started!
42 |
43 | ### v2.1.0
44 |
45 | Check out the new API docs! https://menubuilder.jedfox.com
46 |
47 | New Features:
48 |
49 | * Add `action` and `tag` modifiers (Thanks @patr0nus!)
50 | * Update the SwiftUI custom view wrapper to use Auto Layout (Thanks @rurza!)
51 | * Add support for menu item titles using `AttributedString` on macOS 12+
52 | * Add a convenience initializer to `NSMenu` that takes a `title` and an `@MenuBuilder` closure
53 | * Deprecate the `MenuItem(_:children:)` initializer in favor of `MenuItem(title).submenu { ... children ... }`
54 | * Add an `onSelect(target:action:)` convenience method to set the `target` and `action` at the same time.
55 |
56 | Bugfixes:
57 |
58 | * Update the SwiftUI custom view wrapper’s selection indicator to match the menu styling of macOS Big Sur and later (Thanks @rurza!)
59 | * Make the parameter to `toolTip` optional (Thanks @mlch911!)
60 | * `update()` is now called on an `NSMenu` when you use the `replaceItems` API
61 |
62 | ### v2.0.0
63 | * (**BREAKING**) Migrate to `@resultBuilder` (Xcode 12.5+ is now required)
64 | * Apply modifiers to shortcuts
65 | * Add a `MenuItem.set(WriteableKeyPath, to: Value)` method to make it easier to customize the menu item
66 | * Add a `MenuItem.apply { menuItem in }` method to allow arbitrary customization of the menu item
67 | * Add `IndentGroup` to make it easier to indent several adjacent menu items
68 | * Add `CustomMenuItem` which allows you to include custom subclasses of `NSMenuItem` in a `MenuBuilder`
69 |
70 | ### v1.3.0
71 |
72 | Fixes & cleanup
73 |
74 | ### v1.2.0
75 |
76 | Add loop support
77 |
78 | ### v1.1.0
79 |
80 | Add conditional support
81 |
82 | ### v1.0.1
83 |
84 | Add license, clean up code
85 |
86 | ### v1.0.0
87 |
88 | Initial version!
89 |
90 |
91 | ## Contributing
92 |
93 | Open the `MenuBuilder.xcworkspace` to view the package and demo at the same time. PRs and issues are appreciated!
94 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder Demo/MenuBuilder Demo.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 52;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 85F1D0BE259E6149001831C0 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F1D0BD259E6149001831C0 /* AppDelegate.swift */; };
11 | 85F1D0C0259E6149001831C0 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F1D0BF259E6149001831C0 /* ViewController.swift */; };
12 | 85F1D0C2259E614A001831C0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 85F1D0C1259E614A001831C0 /* Assets.xcassets */; };
13 | 85F1D0C5259E614A001831C0 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85F1D0C3259E614A001831C0 /* Main.storyboard */; };
14 | 85F1D0D2259E6249001831C0 /* MenuBuilder in Frameworks */ = {isa = PBXBuildFile; productRef = 85F1D0D1259E6249001831C0 /* MenuBuilder */; };
15 | /* End PBXBuildFile section */
16 |
17 | /* Begin PBXFileReference section */
18 | 852C57CF28BD251C006411C8 /* MenuBuilder */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MenuBuilder; path = ../..; sourceTree = ""; };
19 | 85F1D0BA259E6149001831C0 /* MenuBuilder Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "MenuBuilder Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; };
20 | 85F1D0BD259E6149001831C0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
21 | 85F1D0BF259E6149001831C0 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; };
22 | 85F1D0C1259E614A001831C0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
23 | 85F1D0C4259E614A001831C0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
24 | 85F1D0C6259E614A001831C0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
25 | 85F1D0C7259E614A001831C0 /* MenuBuilder_Demo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MenuBuilder_Demo.entitlements; sourceTree = ""; };
26 | /* End PBXFileReference section */
27 |
28 | /* Begin PBXFrameworksBuildPhase section */
29 | 85F1D0B7259E6149001831C0 /* Frameworks */ = {
30 | isa = PBXFrameworksBuildPhase;
31 | buildActionMask = 2147483647;
32 | files = (
33 | 85F1D0D2259E6249001831C0 /* MenuBuilder in Frameworks */,
34 | );
35 | runOnlyForDeploymentPostprocessing = 0;
36 | };
37 | /* End PBXFrameworksBuildPhase section */
38 |
39 | /* Begin PBXGroup section */
40 | 85F1D0B1259E6149001831C0 = {
41 | isa = PBXGroup;
42 | children = (
43 | 85F1D0BC259E6149001831C0 /* MenuBuilder Demo */,
44 | 85F1D0BB259E6149001831C0 /* Products */,
45 | 85F1D0D0259E6249001831C0 /* Frameworks */,
46 | 852C57CF28BD251C006411C8 /* MenuBuilder */,
47 | );
48 | sourceTree = "";
49 | };
50 | 85F1D0BB259E6149001831C0 /* Products */ = {
51 | isa = PBXGroup;
52 | children = (
53 | 85F1D0BA259E6149001831C0 /* MenuBuilder Demo.app */,
54 | );
55 | name = Products;
56 | sourceTree = "";
57 | };
58 | 85F1D0BC259E6149001831C0 /* MenuBuilder Demo */ = {
59 | isa = PBXGroup;
60 | children = (
61 | 85F1D0BD259E6149001831C0 /* AppDelegate.swift */,
62 | 85F1D0BF259E6149001831C0 /* ViewController.swift */,
63 | 85F1D0C1259E614A001831C0 /* Assets.xcassets */,
64 | 85F1D0C3259E614A001831C0 /* Main.storyboard */,
65 | 85F1D0C6259E614A001831C0 /* Info.plist */,
66 | 85F1D0C7259E614A001831C0 /* MenuBuilder_Demo.entitlements */,
67 | );
68 | path = "MenuBuilder Demo";
69 | sourceTree = "";
70 | };
71 | 85F1D0D0259E6249001831C0 /* Frameworks */ = {
72 | isa = PBXGroup;
73 | children = (
74 | );
75 | name = Frameworks;
76 | sourceTree = "";
77 | };
78 | /* End PBXGroup section */
79 |
80 | /* Begin PBXNativeTarget section */
81 | 85F1D0B9259E6149001831C0 /* MenuBuilder Demo */ = {
82 | isa = PBXNativeTarget;
83 | buildConfigurationList = 85F1D0CA259E614A001831C0 /* Build configuration list for PBXNativeTarget "MenuBuilder Demo" */;
84 | buildPhases = (
85 | 85F1D0B6259E6149001831C0 /* Sources */,
86 | 85F1D0B7259E6149001831C0 /* Frameworks */,
87 | 85F1D0B8259E6149001831C0 /* Resources */,
88 | );
89 | buildRules = (
90 | );
91 | dependencies = (
92 | );
93 | name = "MenuBuilder Demo";
94 | packageProductDependencies = (
95 | 85F1D0D1259E6249001831C0 /* MenuBuilder */,
96 | );
97 | productName = "MenuBuilder Demo";
98 | productReference = 85F1D0BA259E6149001831C0 /* MenuBuilder Demo.app */;
99 | productType = "com.apple.product-type.application";
100 | };
101 | /* End PBXNativeTarget section */
102 |
103 | /* Begin PBXProject section */
104 | 85F1D0B2259E6149001831C0 /* Project object */ = {
105 | isa = PBXProject;
106 | attributes = {
107 | LastSwiftUpdateCheck = 1220;
108 | LastUpgradeCheck = 1220;
109 | TargetAttributes = {
110 | 85F1D0B9259E6149001831C0 = {
111 | CreatedOnToolsVersion = 12.2;
112 | };
113 | };
114 | };
115 | buildConfigurationList = 85F1D0B5259E6149001831C0 /* Build configuration list for PBXProject "MenuBuilder Demo" */;
116 | compatibilityVersion = "Xcode 9.3";
117 | developmentRegion = en;
118 | hasScannedForEncodings = 0;
119 | knownRegions = (
120 | en,
121 | Base,
122 | );
123 | mainGroup = 85F1D0B1259E6149001831C0;
124 | productRefGroup = 85F1D0BB259E6149001831C0 /* Products */;
125 | projectDirPath = "";
126 | projectRoot = "";
127 | targets = (
128 | 85F1D0B9259E6149001831C0 /* MenuBuilder Demo */,
129 | );
130 | };
131 | /* End PBXProject section */
132 |
133 | /* Begin PBXResourcesBuildPhase section */
134 | 85F1D0B8259E6149001831C0 /* Resources */ = {
135 | isa = PBXResourcesBuildPhase;
136 | buildActionMask = 2147483647;
137 | files = (
138 | 85F1D0C2259E614A001831C0 /* Assets.xcassets in Resources */,
139 | 85F1D0C5259E614A001831C0 /* Main.storyboard in Resources */,
140 | );
141 | runOnlyForDeploymentPostprocessing = 0;
142 | };
143 | /* End PBXResourcesBuildPhase section */
144 |
145 | /* Begin PBXSourcesBuildPhase section */
146 | 85F1D0B6259E6149001831C0 /* Sources */ = {
147 | isa = PBXSourcesBuildPhase;
148 | buildActionMask = 2147483647;
149 | files = (
150 | 85F1D0C0259E6149001831C0 /* ViewController.swift in Sources */,
151 | 85F1D0BE259E6149001831C0 /* AppDelegate.swift in Sources */,
152 | );
153 | runOnlyForDeploymentPostprocessing = 0;
154 | };
155 | /* End PBXSourcesBuildPhase section */
156 |
157 | /* Begin PBXVariantGroup section */
158 | 85F1D0C3259E614A001831C0 /* Main.storyboard */ = {
159 | isa = PBXVariantGroup;
160 | children = (
161 | 85F1D0C4259E614A001831C0 /* Base */,
162 | );
163 | name = Main.storyboard;
164 | sourceTree = "";
165 | };
166 | /* End PBXVariantGroup section */
167 |
168 | /* Begin XCBuildConfiguration section */
169 | 85F1D0C8259E614A001831C0 /* Debug */ = {
170 | isa = XCBuildConfiguration;
171 | buildSettings = {
172 | ALWAYS_SEARCH_USER_PATHS = NO;
173 | CLANG_ANALYZER_NONNULL = YES;
174 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
175 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
176 | CLANG_CXX_LIBRARY = "libc++";
177 | CLANG_ENABLE_MODULES = YES;
178 | CLANG_ENABLE_OBJC_ARC = YES;
179 | CLANG_ENABLE_OBJC_WEAK = YES;
180 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
181 | CLANG_WARN_BOOL_CONVERSION = YES;
182 | CLANG_WARN_COMMA = YES;
183 | CLANG_WARN_CONSTANT_CONVERSION = YES;
184 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
185 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
186 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
187 | CLANG_WARN_EMPTY_BODY = YES;
188 | CLANG_WARN_ENUM_CONVERSION = YES;
189 | CLANG_WARN_INFINITE_RECURSION = YES;
190 | CLANG_WARN_INT_CONVERSION = YES;
191 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
192 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
193 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
194 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
195 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
196 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
197 | CLANG_WARN_STRICT_PROTOTYPES = YES;
198 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
199 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
200 | CLANG_WARN_UNREACHABLE_CODE = YES;
201 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
202 | COPY_PHASE_STRIP = NO;
203 | DEBUG_INFORMATION_FORMAT = dwarf;
204 | ENABLE_STRICT_OBJC_MSGSEND = YES;
205 | ENABLE_TESTABILITY = YES;
206 | GCC_C_LANGUAGE_STANDARD = gnu11;
207 | GCC_DYNAMIC_NO_PIC = NO;
208 | GCC_NO_COMMON_BLOCKS = YES;
209 | GCC_OPTIMIZATION_LEVEL = 0;
210 | GCC_PREPROCESSOR_DEFINITIONS = (
211 | "DEBUG=1",
212 | "$(inherited)",
213 | );
214 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
215 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
216 | GCC_WARN_UNDECLARED_SELECTOR = YES;
217 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
218 | GCC_WARN_UNUSED_FUNCTION = YES;
219 | GCC_WARN_UNUSED_VARIABLE = YES;
220 | MACOSX_DEPLOYMENT_TARGET = 11.0;
221 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
222 | MTL_FAST_MATH = YES;
223 | ONLY_ACTIVE_ARCH = YES;
224 | SDKROOT = macosx;
225 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
226 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
227 | };
228 | name = Debug;
229 | };
230 | 85F1D0C9259E614A001831C0 /* Release */ = {
231 | isa = XCBuildConfiguration;
232 | buildSettings = {
233 | ALWAYS_SEARCH_USER_PATHS = NO;
234 | CLANG_ANALYZER_NONNULL = YES;
235 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
236 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
237 | CLANG_CXX_LIBRARY = "libc++";
238 | CLANG_ENABLE_MODULES = YES;
239 | CLANG_ENABLE_OBJC_ARC = YES;
240 | CLANG_ENABLE_OBJC_WEAK = YES;
241 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
242 | CLANG_WARN_BOOL_CONVERSION = YES;
243 | CLANG_WARN_COMMA = YES;
244 | CLANG_WARN_CONSTANT_CONVERSION = YES;
245 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
246 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
247 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
248 | CLANG_WARN_EMPTY_BODY = YES;
249 | CLANG_WARN_ENUM_CONVERSION = YES;
250 | CLANG_WARN_INFINITE_RECURSION = YES;
251 | CLANG_WARN_INT_CONVERSION = YES;
252 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
253 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
254 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
255 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
256 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
257 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
258 | CLANG_WARN_STRICT_PROTOTYPES = YES;
259 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
260 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
261 | CLANG_WARN_UNREACHABLE_CODE = YES;
262 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
263 | COPY_PHASE_STRIP = NO;
264 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
265 | ENABLE_NS_ASSERTIONS = NO;
266 | ENABLE_STRICT_OBJC_MSGSEND = YES;
267 | GCC_C_LANGUAGE_STANDARD = gnu11;
268 | GCC_NO_COMMON_BLOCKS = YES;
269 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
270 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
271 | GCC_WARN_UNDECLARED_SELECTOR = YES;
272 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
273 | GCC_WARN_UNUSED_FUNCTION = YES;
274 | GCC_WARN_UNUSED_VARIABLE = YES;
275 | MACOSX_DEPLOYMENT_TARGET = 11.0;
276 | MTL_ENABLE_DEBUG_INFO = NO;
277 | MTL_FAST_MATH = YES;
278 | SDKROOT = macosx;
279 | SWIFT_COMPILATION_MODE = wholemodule;
280 | SWIFT_OPTIMIZATION_LEVEL = "-O";
281 | };
282 | name = Release;
283 | };
284 | 85F1D0CB259E614A001831C0 /* Debug */ = {
285 | isa = XCBuildConfiguration;
286 | buildSettings = {
287 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
288 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
289 | CODE_SIGN_ENTITLEMENTS = "MenuBuilder Demo/MenuBuilder_Demo.entitlements";
290 | CODE_SIGN_STYLE = Automatic;
291 | COMBINE_HIDPI_IMAGES = YES;
292 | DEVELOPMENT_TEAM = "";
293 | ENABLE_HARDENED_RUNTIME = YES;
294 | INFOPLIST_FILE = "MenuBuilder Demo/Info.plist";
295 | LD_RUNPATH_SEARCH_PATHS = (
296 | "$(inherited)",
297 | "@executable_path/../Frameworks",
298 | );
299 | PRODUCT_BUNDLE_IDENTIFIER = "com.jedfox.MenuBuilder-Demo";
300 | PRODUCT_NAME = "$(TARGET_NAME)";
301 | SWIFT_VERSION = 5.0;
302 | };
303 | name = Debug;
304 | };
305 | 85F1D0CC259E614A001831C0 /* Release */ = {
306 | isa = XCBuildConfiguration;
307 | buildSettings = {
308 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
309 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
310 | CODE_SIGN_ENTITLEMENTS = "MenuBuilder Demo/MenuBuilder_Demo.entitlements";
311 | CODE_SIGN_STYLE = Automatic;
312 | COMBINE_HIDPI_IMAGES = YES;
313 | DEVELOPMENT_TEAM = "";
314 | ENABLE_HARDENED_RUNTIME = YES;
315 | INFOPLIST_FILE = "MenuBuilder Demo/Info.plist";
316 | LD_RUNPATH_SEARCH_PATHS = (
317 | "$(inherited)",
318 | "@executable_path/../Frameworks",
319 | );
320 | PRODUCT_BUNDLE_IDENTIFIER = "com.jedfox.MenuBuilder-Demo";
321 | PRODUCT_NAME = "$(TARGET_NAME)";
322 | SWIFT_VERSION = 5.0;
323 | };
324 | name = Release;
325 | };
326 | /* End XCBuildConfiguration section */
327 |
328 | /* Begin XCConfigurationList section */
329 | 85F1D0B5259E6149001831C0 /* Build configuration list for PBXProject "MenuBuilder Demo" */ = {
330 | isa = XCConfigurationList;
331 | buildConfigurations = (
332 | 85F1D0C8259E614A001831C0 /* Debug */,
333 | 85F1D0C9259E614A001831C0 /* Release */,
334 | );
335 | defaultConfigurationIsVisible = 0;
336 | defaultConfigurationName = Release;
337 | };
338 | 85F1D0CA259E614A001831C0 /* Build configuration list for PBXNativeTarget "MenuBuilder Demo" */ = {
339 | isa = XCConfigurationList;
340 | buildConfigurations = (
341 | 85F1D0CB259E614A001831C0 /* Debug */,
342 | 85F1D0CC259E614A001831C0 /* Release */,
343 | );
344 | defaultConfigurationIsVisible = 0;
345 | defaultConfigurationName = Release;
346 | };
347 | /* End XCConfigurationList section */
348 |
349 | /* Begin XCSwiftPackageProductDependency section */
350 | 85F1D0D1259E6249001831C0 /* MenuBuilder */ = {
351 | isa = XCSwiftPackageProductDependency;
352 | productName = MenuBuilder;
353 | };
354 | /* End XCSwiftPackageProductDependency section */
355 | };
356 | rootObject = 85F1D0B2259E6149001831C0 /* Project object */;
357 | }
358 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder Demo/MenuBuilder Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder Demo/MenuBuilder Demo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder Demo/MenuBuilder Demo/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // MenuBuilder Demo
4 | //
5 | // Created by Jed Fox on 12/31/20.
6 | //
7 |
8 | import Cocoa
9 |
10 | @main
11 | class AppDelegate: NSObject, NSApplicationDelegate {
12 |
13 |
14 |
15 |
16 | func applicationDidFinishLaunching(_ aNotification: Notification) {
17 | // Insert code here to initialize your application
18 | }
19 |
20 | func applicationWillTerminate(_ aNotification: Notification) {
21 | // Insert code here to tear down your application
22 | }
23 |
24 |
25 | }
26 |
27 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder Demo/MenuBuilder Demo/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder Demo/MenuBuilder Demo/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "mac",
5 | "scale" : "1x",
6 | "size" : "16x16"
7 | },
8 | {
9 | "idiom" : "mac",
10 | "scale" : "2x",
11 | "size" : "16x16"
12 | },
13 | {
14 | "idiom" : "mac",
15 | "scale" : "1x",
16 | "size" : "32x32"
17 | },
18 | {
19 | "idiom" : "mac",
20 | "scale" : "2x",
21 | "size" : "32x32"
22 | },
23 | {
24 | "idiom" : "mac",
25 | "scale" : "1x",
26 | "size" : "128x128"
27 | },
28 | {
29 | "idiom" : "mac",
30 | "scale" : "2x",
31 | "size" : "128x128"
32 | },
33 | {
34 | "idiom" : "mac",
35 | "scale" : "1x",
36 | "size" : "256x256"
37 | },
38 | {
39 | "idiom" : "mac",
40 | "scale" : "2x",
41 | "size" : "256x256"
42 | },
43 | {
44 | "idiom" : "mac",
45 | "scale" : "1x",
46 | "size" : "512x512"
47 | },
48 | {
49 | "idiom" : "mac",
50 | "scale" : "2x",
51 | "size" : "512x512"
52 | }
53 | ],
54 | "info" : {
55 | "author" : "xcode",
56 | "version" : 1
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder Demo/MenuBuilder Demo/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder Demo/MenuBuilder Demo/Base.lproj/Main.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
673 |
674 |
675 |
676 |
677 |
678 |
679 |
680 |
681 |
682 |
683 |
684 |
685 |
686 |
687 |
688 |
689 |
690 |
691 |
692 |
693 |
694 |
695 |
696 |
697 |
698 |
699 |
700 |
701 |
702 |
703 |
704 |
705 |
706 |
707 |
708 |
709 |
710 |
711 |
712 |
722 |
723 |
724 |
725 |
726 |
727 |
728 |
729 |
730 |
731 |
732 |
733 |
734 |
735 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder Demo/MenuBuilder Demo/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIconFile
10 |
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
19 | CFBundleShortVersionString
20 | 1.0
21 | CFBundleVersion
22 | 1
23 | LSMinimumSystemVersion
24 | $(MACOSX_DEPLOYMENT_TARGET)
25 | NSMainStoryboardFile
26 | Main
27 | NSPrincipalClass
28 | NSApplication
29 |
30 |
31 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder Demo/MenuBuilder Demo/MenuBuilder_Demo.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.files.user-selected.read-only
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder Demo/MenuBuilder Demo/ViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewController.swift
3 | // MenuBuilder Demo
4 | //
5 | // Created by Jed Fox on 12/31/20.
6 | //
7 |
8 | import Cocoa
9 | import MenuBuilder
10 | import SwiftUI
11 |
12 | class ViewController: NSViewController {
13 |
14 | var demoMenu: NSMenu!
15 |
16 | @objc
17 | private func printSenderTag(_ sender: NSMenuItem) {
18 | print("tag:", sender.tag)
19 | }
20 |
21 | override func viewDidLoad() {
22 | super.viewDidLoad()
23 |
24 | demoMenu = NSMenu {
25 | MenuItem("Click me")
26 | .onSelect { print("clicked!") }
27 | MenuItem("Item with a highlightable view")
28 | .view(showsHighlight: true) {
29 | Text("Custom view!")
30 | }
31 | .onSelect {
32 | print("clicky!")
33 | }
34 | MenuItem("Item with a view")
35 | .view(showsHighlight: false) {
36 | MyMenuItemView()
37 | }
38 | SeparatorItem()
39 | MenuItem("Show About Panel")
40 | .action(#selector(NSApplication.orderFrontStandardAboutPanel(_:)))
41 | MenuItem("Item with tag")
42 | .tag(42)
43 | .onSelect(target: self, action: #selector(printSenderTag(_:)))
44 | MenuItem("About")
45 | .submenu {
46 | MenuItem("Version 1.2.3")
47 | MenuItem("Copyright 2021")
48 | }
49 | MenuItem("Quit")
50 | .shortcut("q")
51 | .onSelect { NSApp.terminate(nil) }
52 | for word in ["Hello", "World"] {
53 | MenuItem(word)
54 | }
55 | }
56 | }
57 |
58 | @IBAction func onClick(_ sender: NSButton) {
59 | demoMenu.popUp(
60 | positioning: nil,
61 | at: .init(x: sender.bounds.minX, y: sender.bounds.maxY),
62 | in: sender)
63 | }
64 | }
65 |
66 | struct MyMenuItemView: View {
67 | @State var value = 5.0
68 | var body: some View {
69 | HStack {
70 | Slider(value: $value, in: 0...9, step: 1)
71 | Text("Value: \(value)").font(Font.body.monospacedDigit())
72 | }
73 | .padding(.horizontal)
74 | .frame(minWidth: 250)
75 | }
76 | }
77 |
78 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder/AnyMenuItem.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 | #if canImport(SwiftUI)
3 | import SwiftUI
4 | #endif
5 |
6 | /// Modifiers used to customize a ``MenuItem`` or ``CustomMenuItem``.
7 | public protocol AnyMenuItem {
8 | associatedtype Item: NSMenuItem
9 |
10 | /// Calls the given `modifier` to prepare the menu item for display.
11 | func apply(_ modifier: @escaping (Item) -> ()) -> Self
12 | }
13 |
14 | extension AnyMenuItem {
15 | // MARK: Behavior
16 |
17 | /// Runs a closure when the menu item is selected.
18 | ///
19 | /// ## Example
20 | /// ```swift
21 | /// MenuItem("Click Me")
22 | /// .onSelect {
23 | /// print("Hello, world!")
24 | /// }
25 | /// ```
26 | public func onSelect(_ handler: @escaping () -> ()) -> Self {
27 | set(\.representedObject, to: handler)
28 | .onSelect(target: MenuInvoker.shared, action: #selector(MenuInvoker.run(_:)))
29 | }
30 |
31 | /// Set the target and action of the menu item
32 | ///
33 | /// ## Example
34 | /// ```swift
35 | /// MenuItem("Show Tag")
36 | /// .tag(42)
37 | /// .onSelect(target: self, action: #selector(printSenderTag(_:)))
38 | /// ```
39 | public func onSelect(target: AnyObject, action: Selector) -> Self {
40 | apply {
41 | $0.target = target
42 | $0.action = action
43 | }
44 | }
45 |
46 | /// Set the action of the menu item
47 | ///
48 | /// ## Example
49 | /// ```swift
50 | /// MenuItem("Show About Panel")
51 | /// .action(#selector(orderFrontStandardAboutPanel:))
52 | /// ```
53 | public func action(_ action: Selector) -> Self {
54 | set(\.action, to: action)
55 | }
56 |
57 | /// Set the tag of the menu item
58 | ///
59 | /// ## Example
60 | /// ```swift
61 | /// MenuItem("Find…")
62 | /// .action(#selector(NSTextView.performFindPanelAction(_:)))
63 | /// .tag(Int(NSFindPanelAction.showFindPanel.rawValue))
64 | /// ```
65 | public func tag(_ tag: Int) -> Self {
66 | set(\.tag, to: tag)
67 | }
68 |
69 | /// Sets the keyboard shortcut/key equivalent.
70 | ///
71 | /// ## Example
72 | /// ```swift
73 | /// MenuItem("Quit")
74 | /// .shortcut("q")
75 | /// MenuItem("Commit…")
76 | /// .shortcut("c", holding: [.option, .command])
77 | /// ```
78 | public func shortcut(_ shortcut: String, holding modifiers: NSEvent.ModifierFlags = .command) -> Self {
79 | apply {
80 | $0.keyEquivalent = shortcut
81 | $0.keyEquivalentModifierMask = modifiers
82 | }
83 | }
84 |
85 | /// Disables the menu item.
86 | ///
87 | /// Menu items without a `onSelect` handler or submenu are always disabled.
88 | ///
89 | /// ## Example
90 | /// ```swift
91 | /// MenuItem("Version 1.2.3")
92 | ///
93 | /// MenuItem("Go Forward")
94 | /// .onSelect { ... }
95 | /// .disabled(!canGoForward)
96 | ///
97 | /// MenuItem("Take Risky Action")
98 | /// .onSelect { ... }
99 | /// .disabled()
100 | /// ```
101 | public func disabled(_ disabled: Bool = true) -> Self {
102 | set(\.isEnabled, to: !disabled)
103 | }
104 |
105 | /// Sets the submenu for the given menu item using a menu builder.
106 | ///
107 | /// ## Example
108 | /// ```swift
109 | /// MenuItem("New")
110 | /// .submenu {
111 | /// }
112 | /// ```
113 | public func submenu(@MenuBuilder _ items: @escaping () -> [NSMenuItem]) -> Self {
114 | apply {
115 | $0.submenu = NSMenu(title: $0.title, items)
116 | }
117 | }
118 |
119 | /// Set the tooltip displayed when hovering over the menu item.
120 | ///
121 | /// ## Example
122 | /// ```swift
123 | /// for file in folder {
124 | /// MenuItem(file.name)
125 | /// .onSelect { reveal(file) }
126 | /// // allow the user to read the full name even if it overflows
127 | /// .toolTip(file.name)
128 | /// }
129 | /// ```
130 | public func toolTip(_ toolTip: String?) -> Self {
131 | set(\.toolTip, to: toolTip)
132 | }
133 |
134 | // MARK: Appearance
135 |
136 | /// Sets the checked/unchecked/mixed state
137 | ///
138 | /// ## Example
139 | /// ```swift
140 | /// MenuItem("Show")
141 | /// .submenu {
142 | /// for filter in model.filters {
143 | /// MenuItem(filter.name)
144 | /// .onSelect { filter.isEnabled.toggle() }
145 | /// .checked(filter.isEnabled)
146 | /// }
147 | /// }
148 | /// .state(model.allFiltersEnabled
149 | /// ? .on
150 | /// : model.allFiltersDisabled ? .off : .mixed)
151 | /// ```
152 | public func state(_ state: NSControl.StateValue) -> Self {
153 | set(\.state, to: state)
154 | }
155 |
156 | /// Display a custom `NSView` instead of the title or attributed title.
157 | ///
158 | /// The title string must still be specified in order to enable type-to-select to work.
159 | /// You are responsible for drawing the highlighted state (based on `enclosingMenuItem.isHighlighted`).
160 | ///
161 | /// ## Example
162 | /// ```swift
163 | /// MenuItem("\(server.name) is \(server.status.description)")
164 | /// .view(ServerStatusView(server: server))
165 | /// ```
166 | public func view(_ view: NSView) -> Self {
167 | set(\.view, to: view)
168 | }
169 |
170 | #if canImport(SwiftUI)
171 | /// Display a custom SwiftUI `View` instead of the title or attributed title.
172 | ///
173 | /// The passed closure will only be called once.
174 | ///
175 | /// Any views inside a menu item can use the `\.menuItemIsHighlighted`
176 | /// environment value to alter their appearance when highlighted.
177 | ///
178 | /// By default, a selection material (`NSVisualEffectView.Material.selection`) will be drawn behind the view whenever `menuItemIsHighlighted` is `true`. You can disable this and handle highlighting yourself by passing `showsHighlight: false`
179 | ///
180 | /// ## Example
181 | /// ```swift
182 | /// MenuItem("\(server.name) is \(server.status.description)")
183 | /// .view {
184 | /// HStack {
185 | /// Circle()
186 | /// .fill(server.status.color)
187 | /// .frame(height: 8)
188 | /// Text(server.name)
189 | /// Spacer()
190 | /// Text(server.uptime)
191 | /// }
192 | /// }
193 | /// ```
194 | @available(macOS 10.15, *)
195 | public func view(showsHighlight: Bool = true, @ViewBuilder _ content: () -> Content) -> Self {
196 | view(MenuItemView(showsHighlight: showsHighlight, content()))
197 | }
198 | #endif
199 |
200 | /// Sets the image associated with this menu item.
201 | /// ## Example
202 | /// ```swift
203 | /// MenuItem(file.name)
204 | /// .image(NSImage(named: file.iconName))
205 | /// ```
206 | public func image(_ image: NSImage) -> Self {
207 | set(\.image, to: image)
208 | }
209 |
210 | /// Sets an on/off/mixed-state-specific image.
211 |
212 | /// ## Example
213 | /// ```swift
214 | /// MenuItem(file.name)
215 | /// .image(
216 | /// NSImage(systemSymbolName: "line.horizontal.3.decrease.circle", accessibilityDescription: nil),
217 | /// for: .off
218 | /// )
219 | /// .image(
220 | /// NSImage(systemSymbolName: "line.horizontal.3.decrease.circle.fill", accessibilityDescription: nil),
221 | /// for: .on
222 | /// )
223 | /// ```
224 | public func image(_ image: NSImage, for state: NSControl.StateValue) -> Self {
225 | apply { item in
226 | switch state {
227 | case .off: item.offStateImage = image
228 | case .on: item.onStateImage = image
229 | case .mixed: item.mixedStateImage = image
230 | default: fatalError("Unsupported MenuItem state \(state)")
231 | }
232 | }
233 | }
234 |
235 | // MARK: Advanced Customizations
236 |
237 | /// Indent the menu item to the given level
238 | ///
239 | /// For simple indentation, use ``IndentGroup`` (which automatically handles nesting) instead.
240 | ///
241 | /// ## Example
242 | /// ```swift
243 | /// MenuItem("Hello, world!")
244 | /// .indent(level: 2)
245 | /// ```
246 | ///
247 | /// ## See Also
248 | /// - ``IndentGroup``
249 | public func indent(level: Int) -> Self {
250 | set(\.indentationLevel, to: level)
251 | }
252 |
253 | /// Set an arbitrary `keyPath` on the menu item to a value of your choice.
254 | ///
255 | /// ## Example
256 | /// ```swift
257 | /// MenuItem("Save As…")
258 | /// .set(\.isAlternate, to: true)
259 | /// ```
260 | public func set(_ keyPath: ReferenceWritableKeyPath- , to value: Value) -> Self {
261 | apply {
262 | $0[keyPath: keyPath] = value
263 | }
264 | }
265 | }
266 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder/CustomMenuItem.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 |
3 | /// A menu item made from a custom subclass of `NSMenuItem`.
4 | ///
5 | /// Pass a closure to the ``CustomMenuItem/init(_:)`` initializer that returns a menu item of your desired class, or simply construct the menu item directly:
6 | /// ```swift
7 | /// CustomMenuItem {
8 | /// let item = MyDelegateMenuItem()
9 | /// item.delegate = self
10 | /// return item
11 | /// }
12 | /// // or:
13 | /// CustomMenuItem(MySimpleMenuItem())
14 | /// ```
15 | public struct CustomMenuItem: AnyMenuItem {
16 | public typealias Modifier = (Item) -> ()
17 |
18 | fileprivate let makeMenu: () -> Item
19 | fileprivate let modifiers: [Modifier]
20 |
21 | public init(_ makeMenu: @autoclosure @escaping () -> Item) {
22 | self.makeMenu = makeMenu
23 | self.modifiers = []
24 | }
25 |
26 | /// Calls the provided closure on the `NSMenuItem`, allowing you to apply arbitrary changes.
27 | public func apply(_ modifier: @escaping Modifier) -> Self {
28 | Self(makeMenu: makeMenu, modifiers: modifiers + [modifier])
29 | }
30 | private init(makeMenu: @escaping () -> Item, modifiers: [Modifier]) {
31 | self.makeMenu = makeMenu
32 | self.modifiers = modifiers
33 | }
34 | }
35 |
36 | extension MenuBuilder {
37 | public static func buildExpression(_ expr: CustomMenuItem?) -> [NSMenuItem] {
38 | if let description = expr {
39 | let item = description.makeMenu()
40 | description.modifiers.forEach { $0(item) }
41 | return [item]
42 | }
43 | return []
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder/Documentation.docc/Documentation.md:
--------------------------------------------------------------------------------
1 | # ``MenuBuilder``
2 |
3 | Swift Function Builder for creating `NSMenuItem`s
4 |
5 |
6 | ## Overview
7 |
8 | A function builder for `NSMenu`s, similar in spirit to SwiftUI’s `ViewBuilder`.
9 |
10 | Usage example (see “MenuBuilder Demo” for more details):
11 |
12 | ```swift
13 | let menu = NSMenu {
14 | MenuItem("Click me")
15 | .onSelect { print("clicked!") }
16 | MenuItem("Item with a view")
17 | .view {
18 | MyMenuItemView() // any SwiftUI view
19 | }
20 | SeparatorItem()
21 | MenuItem("About") {
22 | // rendered as disabled items in a submenu
23 | MenuItem("Version 1.2.3")
24 | MenuItem("Copyright 2021")
25 | }
26 | MenuItem("Quit")
27 | .shortcut("q")
28 | .onSelect { NSApp.terminate(nil) }
29 | }
30 |
31 | // later, to replace the menu items with different/updated ones:
32 | menu.replaceItems {
33 | MenuItem("Replaced item").onSelect { print("Hello!") }
34 | }
35 | ```
36 |
37 | ## Installing Menu Items
38 |
39 | DocC does not currently support documentation for extensions of system APIs, so here is the interface of `MenuBuilder`’s extension to `NSMenu`:
40 |
41 | ```swift
42 | extension NSMenu {
43 | /// Create a new menu with the given items.
44 | init(@MenuBuilder _ items: () -> [NSMenuItem])
45 |
46 | /// Remove all items in the menu and replace them
47 | /// with the provided list of menu items.
48 | func replaceItems(@MenuBuilder with items: () -> [NSMenuItem])
49 | }
50 |
51 | ```
52 |
53 | ## Topics
54 |
55 | ### Creating Menu Items
56 |
57 | - ``MenuItem``
58 | - ``CustomMenuItem``
59 | - ``AnyMenuItem``
60 | - ``SeparatorItem``
61 |
62 | ### Constructing Menus
63 |
64 | - ``IndentGroup``
65 | - ``MenuBuilder/MenuBuilder``
66 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder/Documentation.docc/Extensions/AnyMenuItem.md:
--------------------------------------------------------------------------------
1 | # ``MenuBuilder/AnyMenuItem``
2 |
3 | ## Overview
4 |
5 | Any modifiers you provide when creating a given menu item are applied in order to the menu item instance.
6 |
7 | ## Topics
8 |
9 | ### Behavior
10 |
11 | - ``onSelect(_:)``
12 | - ``shortcut(_:holding:)``
13 | - ``disabled(_:)``
14 | - ``toolTip(_:)``
15 |
16 | ### Appearance
17 |
18 | - ``state(_:)``
19 | - ``view(_:)``
20 | - ``view(showsHighlight:_:)``
21 | - ``image(_:)``
22 | - ``image(_:for:)``
23 |
24 | ### Advanced Customizations
25 |
26 | - ``indent(level:)``
27 | - ``set(_:to:)``
28 | - ``apply(_:)``
29 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder/IndentGroup.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IndentGroup.swift
3 | //
4 | //
5 | // Created by Jed Fox on 6/19/21.
6 | //
7 |
8 | import Cocoa
9 |
10 | /// A container that increases the `indentationLevel` of its content by one.
11 | ///
12 | /// ## Example
13 | /// ```swift
14 | /// MenuItem("Connected Interfaces")
15 | /// IndentGroup {
16 | /// MenuItem("Wi-Fi")
17 | /// MenuItem("Bluetooth")
18 | /// }
19 | /// ```
20 | public struct IndentGroup {
21 | fileprivate let children: () -> [NSMenuItem?]
22 |
23 | public init(@MenuBuilder children: @escaping () -> [NSMenuItem?]) {
24 | self.children = children
25 | }
26 | }
27 |
28 | extension MenuBuilder {
29 | public static func buildExpression(_ expr: IndentGroup?) -> [NSMenuItem] {
30 | if let items = expr?.children().compactMap({ $0 }) {
31 | for item in items {
32 | item.indentationLevel += 1
33 | }
34 | return items
35 | }
36 | return []
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder/MenuBuilder.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 |
3 | /// A function builder type that produces an array of `NSMenuItem`s.
4 | @resultBuilder
5 | public struct MenuBuilder {
6 | public static func buildBlock(_ block: [NSMenuItem]...) -> [NSMenuItem] {
7 | block.flatMap { $0 }
8 | }
9 |
10 | public static func buildOptional(_ item: [NSMenuItem]?) -> [NSMenuItem] {
11 | item ?? []
12 | }
13 |
14 | public static func buildEither(first: [NSMenuItem]?) -> [NSMenuItem] {
15 | first ?? []
16 | }
17 | public static func buildEither(second: [NSMenuItem]?) -> [NSMenuItem] {
18 | second ?? []
19 | }
20 |
21 | public static func buildArray(_ components: [[NSMenuItem]]) -> [NSMenuItem] {
22 | components.flatMap { $0 }
23 | }
24 |
25 | public static func buildExpression(_ expr: [NSMenuItem]?) -> [NSMenuItem] {
26 | expr ?? []
27 | }
28 |
29 | public static func buildExpression(_ expr: NSMenuItem?) -> [NSMenuItem] {
30 | expr.map { [$0] } ?? []
31 | }
32 | }
33 |
34 | extension NSMenu {
35 | /// Create a new menu with the given title and items.
36 | public convenience init(title: String, @MenuBuilder _ items: () -> [NSMenuItem]) {
37 | self.init(title: title)
38 | self.replaceItems(with: items)
39 | }
40 |
41 | /// Create a new menu with the given items.
42 | public convenience init(@MenuBuilder _ items: () -> [NSMenuItem]) {
43 | self.init()
44 | self.replaceItems(with: items)
45 | }
46 |
47 | /// Remove all items in the menu and replace them with the provided list of menu items.
48 | public func replaceItems(@MenuBuilder with items: () -> [NSMenuItem]) {
49 | self.removeAllItems()
50 | for item in items() {
51 | self.addItem(item)
52 | }
53 | self.update()
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder/MenuInvoker.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 |
3 | /// A singleton class that calls the closure-based `onSelect` handlers of menu items
4 | class MenuInvoker {
5 | static let shared = MenuInvoker()
6 | private init() {}
7 | @objc func run(_ item: NSMenuItem) {
8 | (item.representedObject as! () -> ())()
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder/MenuItem.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 |
3 | /// A standard menu item.
4 | ///
5 | /// See ``AnyMenuItem`` for a listing of supported modifiers.
6 | public struct MenuItem: AnyMenuItem {
7 | public typealias Modifier = (NSMenuItem) -> ()
8 | /// An array of functions that configure the menu item instance
9 | /// These may be called to update an existing menu item.
10 | fileprivate let modifiers: [Modifier]
11 |
12 | public func apply(_ modifier: @escaping Modifier) -> Self {
13 | Self(modifiers: modifiers + [modifier])
14 | }
15 | private init(modifiers: [Modifier]) {
16 | self.modifiers = modifiers
17 | }
18 |
19 | /// Creates a menu item with the given title.
20 | ///
21 | /// When targeting macOS 12 or later, `MenuBuilder("some title")` will use
22 | /// this initializer for automatic localization support.
23 | @available(macOS 12, *)
24 | public init(
25 | _ s: String.LocalizationValue,
26 | table: String? = nil,
27 | bundle: Bundle? = nil,
28 | locale: Locale = .current,
29 | comment: StaticString? = nil
30 | ) {
31 | modifiers = [{ item in
32 | item.title = String(localized: s, table: table, bundle: bundle, locale: locale, comment: comment)
33 | }]
34 | }
35 |
36 | /// Creates a menu item with the given (non-localized) title.
37 | @_disfavoredOverload
38 | public init(_ title: String) {
39 | modifiers = [{ item in item.title = title }]
40 | }
41 |
42 | /// Creates a menu item with the given (non-localized) title.
43 | public init(verbatim title: String) {
44 | modifiers = [{ item in item.title = title }]
45 | }
46 |
47 | /// Creates a menu item with the given localized string key used as the title.
48 | public init(localized title: String, table: String? = nil, bundle: Bundle = .main) {
49 | modifiers = [{ item in item.title = bundle.localizedString(forKey: title, value: nil, table: table) }]
50 | }
51 |
52 | /// Creates a menu item with the given attributed title.
53 | public init(_ title: NSAttributedString) {
54 | modifiers = [{ item in
55 | item.title = title.string
56 | item.attributedTitle = title
57 | }]
58 | }
59 |
60 | /// Creates a menu item with the given attributed title.
61 | @available(macOS 12, *)
62 | @_disfavoredOverload
63 | public init(_ title: AttributedString) {
64 | modifiers = [{ item in
65 | item.title = title.description
66 | item.attributedTitle = NSAttributedString(title)
67 | }]
68 | }
69 |
70 | /// Creates a menu item with a submenu containing the provided children.
71 | ///
72 | /// > Important: This initializer is deprecated.
73 | /// > Instead, use ``AnyMenuItem/submenu(_:)`` to specify the submenu after initialization:
74 | /// >
75 | /// > ```swift
76 | /// > MenuItem(title).submenu { ... }
77 | /// > ```
78 | @available(*, deprecated, message: "Use .submenu(_:) instead")
79 | public init(_ title: String, @MenuBuilder children: @escaping () -> [NSMenuItem]) {
80 | self = MenuItem(title).submenu(children)
81 | }
82 | }
83 |
84 | extension MenuBuilder {
85 | public static func buildExpression(_ expr: MenuItem?) -> [NSMenuItem] {
86 | if let description = expr {
87 | let item = NSMenuItem()
88 | description.modifiers.forEach { $0(item) }
89 | return [item]
90 | }
91 | return []
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder/MenuItemView.swift:
--------------------------------------------------------------------------------
1 | #if canImport(SwiftUI)
2 | import Cocoa
3 | import SwiftUI
4 |
5 | @available(macOS 10.15, *)
6 | extension EnvironmentValues {
7 | private struct HighlightedKey: EnvironmentKey {
8 | static let defaultValue = false
9 | }
10 |
11 | /// Only updated inside of a `MenuItem(...).view { ... }` closure.
12 | /// Use this to adjust your content to look good in front of the selection background
13 | public var menuItemIsHighlighted: Bool {
14 | get {
15 | return self[HighlightedKey.self]
16 | }
17 | set {
18 | self[HighlightedKey.self] = newValue
19 | }
20 | }
21 | }
22 |
23 | /// A custom menu item view that manages highlight state and renders
24 | /// an appropriate backdrop behind the view when highlighted
25 | @available(macOS 10.15, *)
26 | class MenuItemView: NSView {
27 | private var effectView: NSVisualEffectView
28 | let contentView: ContentView
29 | let hostView: NSHostingView
30 | let showsHighlight: Bool
31 |
32 | init(showsHighlight: Bool, _ view: ContentView) {
33 | effectView = NSVisualEffectView()
34 | contentView = view
35 | hostView = NSHostingView(rootView: AnyView(contentView))
36 |
37 | self.showsHighlight = showsHighlight
38 |
39 | super.init(frame: CGRect(origin: .zero, size: hostView.fittingSize))
40 | addSubview(effectView)
41 | addSubview(hostView)
42 |
43 | setUpEffectView()
44 | setUpConstraints()
45 | }
46 |
47 | required init?(coder decoder: NSCoder) {
48 | fatalError("init(coder:) has not been implemented")
49 | }
50 |
51 |
52 | override func draw(_ dirtyRect: NSRect) {
53 | let highlighted = enclosingMenuItem!.isHighlighted
54 | effectView.isHidden = !showsHighlight || !highlighted
55 | hostView.rootView = AnyView(contentView.environment(\.menuItemIsHighlighted, highlighted))
56 | super.draw(dirtyRect)
57 | }
58 |
59 | private func setUpEffectView() {
60 | effectView.state = .active
61 | effectView.material = .selection
62 | effectView.isEmphasized = true
63 | effectView.blendingMode = .behindWindow
64 | effectView.wantsLayer = true
65 | effectView.layer?.cornerRadius = 4
66 | effectView.layer?.cornerCurve = .continuous
67 | }
68 |
69 | private func setUpConstraints() {
70 | effectView.translatesAutoresizingMaskIntoConstraints = false
71 | hostView.translatesAutoresizingMaskIntoConstraints = false
72 | translatesAutoresizingMaskIntoConstraints = false
73 |
74 | let margin: CGFloat = 5
75 | effectView.topAnchor.constraint(equalTo: topAnchor).isActive = true
76 | effectView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: margin).isActive = true
77 | effectView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
78 | effectView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -margin).isActive = true
79 |
80 | hostView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
81 | hostView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
82 | hostView.topAnchor.constraint(equalTo: topAnchor).isActive = true
83 | hostView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
84 | }
85 | }
86 | #endif
87 |
88 |
--------------------------------------------------------------------------------
/Sources/MenuBuilder/SeparatorItem.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 |
3 | /// A separator item.
4 | public struct SeparatorItem {
5 | public init() {}
6 | }
7 |
8 | extension MenuBuilder {
9 | public static func buildExpression(_ expr: SeparatorItem?) -> [NSMenuItem] {
10 | expr != nil ? [.separator()] : []
11 | }
12 | }
13 |
14 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import MenuBuilderTests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += MenuBuilderTests.allTests()
7 | XCTMain(tests)
8 |
--------------------------------------------------------------------------------
/Tests/MenuBuilderTests/MenuBuilderTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import MenuBuilder
3 |
4 | fileprivate class MyMenuItem: NSMenuItem {
5 | var value: Int?
6 | }
7 |
8 | final class MenuBuilderTests: XCTestCase {
9 | func testExample() {
10 | // This is an example of a functional test case.
11 | // Use XCTAssert and related functions to verify your tests produce the correct
12 | // results.
13 | let menu = NSMenu {
14 | MenuItem("Hello, world!")
15 | SeparatorItem()
16 | MenuItem("Title")
17 | IndentGroup {
18 | MenuItem("Item 1")
19 | IndentGroup {
20 | MenuItem("Item 2")
21 | }
22 | MenuItem("Item 3")
23 | }
24 | CustomMenuItem(MyMenuItem())
25 | .set(\.value, to: 42)
26 | }
27 | XCTAssertEqual(menu.items.count, 7)
28 | XCTAssertEqual(menu.items[0].title, "Hello, world!")
29 | XCTAssertTrue(menu.items[1].isSeparatorItem)
30 | XCTAssertEqual(menu.items.map(\.indentationLevel), [0, 0, 0, 1, 2, 1, 0])
31 | XCTAssertNotNil(menu.items[6] as? MyMenuItem)
32 | XCTAssertEqual((menu.items[6] as? MyMenuItem)?.value, 42)
33 | }
34 |
35 | static var allTests = [
36 | ("testExample", testExample),
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/Tests/MenuBuilderTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(MenuBuilderTests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------