├── .editorconfig
├── .gitattributes
├── .github
└── workflows
│ └── swiftlint.yml
├── .gitignore
├── .spi.yml
├── .swiftlint.yml
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── contents.xcworkspacedata
│ └── xcshareddata
│ └── xcschemes
│ └── KeyboardShortcuts.xcscheme
├── Example
├── KeyboardShortcutsExample.xcodeproj
│ └── project.pbxproj
└── KeyboardShortcutsExample
│ ├── App.swift
│ ├── AppState.swift
│ ├── Assets.xcassets
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ └── Contents.json
│ └── Contents.json
│ ├── Info.plist
│ ├── KeyboardShortcutsExample.entitlements
│ ├── MainScreen.swift
│ ├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
│ └── Utilities.swift
├── Package.swift
├── Sources
└── KeyboardShortcuts
│ ├── CarbonKeyboardShortcuts.swift
│ ├── Key.swift
│ ├── KeyboardShortcuts.swift
│ ├── Localization
│ ├── ar.lproj
│ │ └── Localizable.strings
│ ├── cs.lproj
│ │ └── Localizable.strings
│ ├── de.lproj
│ │ └── Localizable.strings
│ ├── en.lproj
│ │ └── Localizable.strings
│ ├── es.lproj
│ │ └── Localizable.strings
│ ├── fr.lproj
│ │ └── Localizable.strings
│ ├── hu.lproj
│ │ └── Localizable.strings
│ ├── ja.lproj
│ │ └── Localizable.strings
│ ├── nl.lproj
│ │ └── Localizable.strings
│ ├── ru.lproj
│ │ └── Localizable.strings
│ ├── zh-Hans.lproj
│ │ └── Localizable.strings
│ └── zh-TW.lproj
│ │ └── Localizable.strings
│ ├── NSMenuItem++.swift
│ ├── Name.swift
│ ├── Recorder.swift
│ ├── RecorderCocoa.swift
│ ├── Shortcut.swift
│ ├── Utilities.swift
│ └── ViewModifiers.swift
├── Tests
└── KeyboardShortcutsTests
│ ├── KeyboardShortcutsTests.swift
│ └── Utilities.swift
├── license
├── logo-dark.png
├── logo-light.png
├── readme.md
└── screenshot.png
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [*.yml]
11 | indent_style = space
12 | indent_size = 2
13 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.github/workflows/swiftlint.yml:
--------------------------------------------------------------------------------
1 | name: SwiftLint
2 | on:
3 | pull_request:
4 | paths:
5 | - '.github/workflows/swiftlint.yml'
6 | - '.swiftlint.yml'
7 | - '**/*.swift'
8 | jobs:
9 | lint:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - name: SwiftLint
14 | uses: norio-nomura/action-swiftlint@3.2.1
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.build
2 | /Packages
3 | xcuserdata
4 | project.xcworkspace
5 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: ['KeyboardShortcuts']
5 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | only_rules:
2 | - anyobject_protocol
3 | - array_init
4 | - block_based_kvo
5 | - class_delegate_protocol
6 | - closing_brace
7 | - closure_end_indentation
8 | - closure_parameter_position
9 | - closure_spacing
10 | - collection_alignment
11 | - colon
12 | - comma
13 | - compiler_protocol_init
14 | - computed_accessors_order
15 | - conditional_returns_on_newline
16 | - contains_over_filter_count
17 | - contains_over_filter_is_empty
18 | - contains_over_first_not_nil
19 | - contains_over_range_nil_comparison
20 | - control_statement
21 | - custom_rules
22 | - deployment_target
23 | - discarded_notification_center_observer
24 | - discouraged_assert
25 | - discouraged_direct_init
26 | - discouraged_none_name
27 | - discouraged_object_literal
28 | - discouraged_optional_boolean
29 | - discouraged_optional_collection
30 | - duplicate_enum_cases
31 | - duplicate_imports
32 | - duplicated_key_in_dictionary_literal
33 | - dynamic_inline
34 | - empty_collection_literal
35 | - empty_count
36 | - empty_enum_arguments
37 | - empty_parameters
38 | - empty_parentheses_with_trailing_closure
39 | - empty_string
40 | - empty_xctest_method
41 | - enum_case_associated_values_count
42 | - explicit_init
43 | - fallthrough
44 | - fatal_error_message
45 | - first_where
46 | - flatmap_over_map_reduce
47 | - for_where
48 | - generic_type_name
49 | - ibinspectable_in_extension
50 | - identical_operands
51 | - identifier_name
52 | - implicit_getter
53 | - implicit_return
54 | - inclusive_language
55 | - inert_defer
56 | - is_disjoint
57 | - joined_default_parameter
58 | - last_where
59 | - leading_whitespace
60 | - legacy_cggeometry_functions
61 | - legacy_constant
62 | - legacy_constructor
63 | - legacy_hashing
64 | - legacy_multiple
65 | - legacy_nsgeometry_functions
66 | - legacy_random
67 | - literal_expression_end_indentation
68 | - lower_acl_than_parent
69 | - mark
70 | - modifier_order
71 | - multiline_arguments
72 | - multiline_function_chains
73 | - multiline_literal_brackets
74 | - multiline_parameters
75 | - multiline_parameters_brackets
76 | - nimble_operator
77 | - no_extension_access_modifier
78 | - no_fallthrough_only
79 | - no_space_in_method_call
80 | - notification_center_detachment
81 | - nsobject_prefer_isequal
82 | - number_separator
83 | - opening_brace
84 | - operator_usage_whitespace
85 | - operator_whitespace
86 | - orphaned_doc_comment
87 | - overridden_super_call
88 | - prefer_self_type_over_type_of_self
89 | - prefer_zero_over_explicit_init
90 | - private_action
91 | - private_outlet
92 | - private_subject
93 | - private_unit_test
94 | - prohibited_super_call
95 | - protocol_property_accessors_order
96 | - reduce_boolean
97 | - reduce_into
98 | - redundant_discardable_let
99 | - redundant_nil_coalescing
100 | - redundant_objc_attribute
101 | - redundant_optional_initialization
102 | - redundant_set_access_control
103 | - redundant_string_enum_value
104 | - redundant_type_annotation
105 | - redundant_void_return
106 | - required_enum_case
107 | - return_arrow_whitespace
108 | - shorthand_operator
109 | - sorted_first_last
110 | - statement_position
111 | - static_operator
112 | - strong_iboutlet
113 | - superfluous_disable_command
114 | - switch_case_alignment
115 | - switch_case_on_newline
116 | - syntactic_sugar
117 | - test_case_accessibility
118 | - toggle_bool
119 | - trailing_closure
120 | - trailing_comma
121 | - trailing_newline
122 | - trailing_semicolon
123 | - trailing_whitespace
124 | - unavailable_function
125 | - unneeded_break_in_switch
126 | - unneeded_parentheses_in_closure_argument
127 | - unowned_variable_capture
128 | - untyped_error_in_catch
129 | - unused_capture_list
130 | - unused_closure_parameter
131 | - unused_control_flow_label
132 | - unused_enumerated
133 | - unused_optional_binding
134 | - unused_setter_value
135 | - valid_ibinspectable
136 | - vertical_parameter_alignment
137 | - vertical_parameter_alignment_on_call
138 | - vertical_whitespace_closing_braces
139 | - vertical_whitespace_opening_braces
140 | - void_return
141 | - xct_specific_matcher
142 | - xctfail_message
143 | - yoda_condition
144 | analyzer_rules:
145 | - capture_variable
146 | - unused_declaration
147 | - unused_import
148 | number_separator:
149 | minimum_length: 5
150 | identifier_name:
151 | max_length:
152 | warning: 100
153 | error: 100
154 | min_length:
155 | warning: 2
156 | error: 2
157 | validates_start_with_lowercase: false
158 | allowed_symbols:
159 | - '_'
160 | excluded:
161 | - 'x'
162 | - 'y'
163 | - 'z'
164 | - 'a'
165 | - 'b'
166 | - 'x1'
167 | - 'x2'
168 | - 'y1'
169 | - 'y2'
170 | - 'z2'
171 | deployment_target:
172 | macOS_deployment_target: '10.11'
173 | custom_rules:
174 | no_nsrect:
175 | regex: '\bNSRect\b'
176 | match_kinds: typeidentifier
177 | message: 'Use CGRect instead of NSRect'
178 | no_nssize:
179 | regex: '\bNSSize\b'
180 | match_kinds: typeidentifier
181 | message: 'Use CGSize instead of NSSize'
182 | no_nspoint:
183 | regex: '\bNSPoint\b'
184 | match_kinds: typeidentifier
185 | message: 'Use CGPoint instead of NSPoint'
186 | no_cgfloat:
187 | regex: '\bCGFloat\b'
188 | match_kinds: typeidentifier
189 | message: 'Use Double instead of CGFloat'
190 | no_cgfloat2:
191 | regex: '\bCGFloat\('
192 | message: 'Use Double instead of CGFloat'
193 | swiftui_state_private:
194 | regex: '@(State|StateObject|ObservedObject|EnvironmentObject)\s+var'
195 | message: 'SwiftUI @State/@StateObject/@ObservedObject/@EnvironmentObject properties should be private'
196 | swiftui_environment_private:
197 | regex: '@Environment\(\\\.\w+\)\s+var'
198 | message: 'SwiftUI @Environment properties should be private'
199 | final_class:
200 | regex: '^class [a-zA-Z\d]+[^{]+\{'
201 | message: 'Classes should be marked as final whenever possible. If you actually need it to be subclassable, just add `// swiftlint:disable:next final_class`.'
202 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/KeyboardShortcuts.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
44 |
45 |
47 |
53 |
54 |
55 |
56 |
57 |
67 |
68 |
74 |
75 |
81 |
82 |
83 |
84 |
86 |
87 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 56;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | E33F1EFC26F3B89C00ACEB0F /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = E33F1EFB26F3B89C00ACEB0F /* KeyboardShortcuts */; };
11 | E36FB94A2609BA43004272D9 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB9492609BA43004272D9 /* App.swift */; };
12 | E36FB94C2609BA43004272D9 /* MainScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB94B2609BA43004272D9 /* MainScreen.swift */; };
13 | E36FB94E2609BA45004272D9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E36FB94D2609BA45004272D9 /* Assets.xcassets */; };
14 | E36FB9512609BA45004272D9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E36FB9502609BA45004272D9 /* Preview Assets.xcassets */; };
15 | E36FB9632609BB83004272D9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB9622609BB83004272D9 /* AppState.swift */; };
16 | E36FB9662609BF3D004272D9 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB9652609BF3D004272D9 /* Utilities.swift */; };
17 | /* End PBXBuildFile section */
18 |
19 | /* Begin PBXFileReference section */
20 | E33F1EFA26F3B78800ACEB0F /* KeyboardShortcuts */ = {isa = PBXFileReference; lastKnownFileType = folder; name = KeyboardShortcuts; path = ..; sourceTree = ""; };
21 | E36FB9462609BA43004272D9 /* KeyboardShortcutsExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardShortcutsExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
22 | E36FB9492609BA43004272D9 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; };
23 | E36FB94B2609BA43004272D9 /* MainScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreen.swift; sourceTree = ""; };
24 | E36FB94D2609BA45004272D9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
25 | E36FB9502609BA45004272D9 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
26 | E36FB9522609BA45004272D9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
27 | E36FB9532609BA45004272D9 /* KeyboardShortcutsExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeyboardShortcutsExample.entitlements; sourceTree = ""; };
28 | E36FB9622609BB83004272D9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; };
29 | E36FB9652609BF3D004272D9 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; };
30 | /* End PBXFileReference section */
31 |
32 | /* Begin PBXFrameworksBuildPhase section */
33 | E36FB9432609BA43004272D9 /* Frameworks */ = {
34 | isa = PBXFrameworksBuildPhase;
35 | buildActionMask = 2147483647;
36 | files = (
37 | E33F1EFC26F3B89C00ACEB0F /* KeyboardShortcuts in Frameworks */,
38 | );
39 | runOnlyForDeploymentPostprocessing = 0;
40 | };
41 | /* End PBXFrameworksBuildPhase section */
42 |
43 | /* Begin PBXGroup section */
44 | E33F1EF926F3B78800ACEB0F /* Packages */ = {
45 | isa = PBXGroup;
46 | children = (
47 | E33F1EFA26F3B78800ACEB0F /* KeyboardShortcuts */,
48 | );
49 | name = Packages;
50 | sourceTree = "";
51 | };
52 | E36FB93D2609BA43004272D9 = {
53 | isa = PBXGroup;
54 | children = (
55 | E36FB9482609BA43004272D9 /* KeyboardShortcutsExample */,
56 | E36FB9472609BA43004272D9 /* Products */,
57 | E33F1EF926F3B78800ACEB0F /* Packages */,
58 | );
59 | sourceTree = "";
60 | };
61 | E36FB9472609BA43004272D9 /* Products */ = {
62 | isa = PBXGroup;
63 | children = (
64 | E36FB9462609BA43004272D9 /* KeyboardShortcutsExample.app */,
65 | );
66 | name = Products;
67 | sourceTree = "";
68 | };
69 | E36FB9482609BA43004272D9 /* KeyboardShortcutsExample */ = {
70 | isa = PBXGroup;
71 | children = (
72 | E36FB9492609BA43004272D9 /* App.swift */,
73 | E36FB9622609BB83004272D9 /* AppState.swift */,
74 | E36FB94B2609BA43004272D9 /* MainScreen.swift */,
75 | E36FB9652609BF3D004272D9 /* Utilities.swift */,
76 | E36FB94D2609BA45004272D9 /* Assets.xcassets */,
77 | E36FB9522609BA45004272D9 /* Info.plist */,
78 | E36FB9532609BA45004272D9 /* KeyboardShortcutsExample.entitlements */,
79 | E36FB94F2609BA45004272D9 /* Preview Content */,
80 | );
81 | path = KeyboardShortcutsExample;
82 | sourceTree = "";
83 | };
84 | E36FB94F2609BA45004272D9 /* Preview Content */ = {
85 | isa = PBXGroup;
86 | children = (
87 | E36FB9502609BA45004272D9 /* Preview Assets.xcassets */,
88 | );
89 | path = "Preview Content";
90 | sourceTree = "";
91 | };
92 | /* End PBXGroup section */
93 |
94 | /* Begin PBXNativeTarget section */
95 | E36FB9452609BA43004272D9 /* KeyboardShortcutsExample */ = {
96 | isa = PBXNativeTarget;
97 | buildConfigurationList = E36FB9562609BA45004272D9 /* Build configuration list for PBXNativeTarget "KeyboardShortcutsExample" */;
98 | buildPhases = (
99 | E36FB9422609BA43004272D9 /* Sources */,
100 | E36FB9432609BA43004272D9 /* Frameworks */,
101 | E36FB9442609BA43004272D9 /* Resources */,
102 | );
103 | buildRules = (
104 | );
105 | dependencies = (
106 | );
107 | name = KeyboardShortcutsExample;
108 | packageProductDependencies = (
109 | E33F1EFB26F3B89C00ACEB0F /* KeyboardShortcuts */,
110 | );
111 | productName = KeyboardShortcutsExample;
112 | productReference = E36FB9462609BA43004272D9 /* KeyboardShortcutsExample.app */;
113 | productType = "com.apple.product-type.application";
114 | };
115 | /* End PBXNativeTarget section */
116 |
117 | /* Begin PBXProject section */
118 | E36FB93E2609BA43004272D9 /* Project object */ = {
119 | isa = PBXProject;
120 | attributes = {
121 | LastSwiftUpdateCheck = 1240;
122 | LastUpgradeCheck = 1410;
123 | TargetAttributes = {
124 | E36FB9452609BA43004272D9 = {
125 | CreatedOnToolsVersion = 12.4;
126 | };
127 | };
128 | };
129 | buildConfigurationList = E36FB9412609BA43004272D9 /* Build configuration list for PBXProject "KeyboardShortcutsExample" */;
130 | compatibilityVersion = "Xcode 14.0";
131 | developmentRegion = en;
132 | hasScannedForEncodings = 0;
133 | knownRegions = (
134 | en,
135 | Base,
136 | );
137 | mainGroup = E36FB93D2609BA43004272D9;
138 | productRefGroup = E36FB9472609BA43004272D9 /* Products */;
139 | projectDirPath = "";
140 | projectRoot = "";
141 | targets = (
142 | E36FB9452609BA43004272D9 /* KeyboardShortcutsExample */,
143 | );
144 | };
145 | /* End PBXProject section */
146 |
147 | /* Begin PBXResourcesBuildPhase section */
148 | E36FB9442609BA43004272D9 /* Resources */ = {
149 | isa = PBXResourcesBuildPhase;
150 | buildActionMask = 2147483647;
151 | files = (
152 | E36FB9512609BA45004272D9 /* Preview Assets.xcassets in Resources */,
153 | E36FB94E2609BA45004272D9 /* Assets.xcassets in Resources */,
154 | );
155 | runOnlyForDeploymentPostprocessing = 0;
156 | };
157 | /* End PBXResourcesBuildPhase section */
158 |
159 | /* Begin PBXSourcesBuildPhase section */
160 | E36FB9422609BA43004272D9 /* Sources */ = {
161 | isa = PBXSourcesBuildPhase;
162 | buildActionMask = 2147483647;
163 | files = (
164 | E36FB9632609BB83004272D9 /* AppState.swift in Sources */,
165 | E36FB94C2609BA43004272D9 /* MainScreen.swift in Sources */,
166 | E36FB9662609BF3D004272D9 /* Utilities.swift in Sources */,
167 | E36FB94A2609BA43004272D9 /* App.swift in Sources */,
168 | );
169 | runOnlyForDeploymentPostprocessing = 0;
170 | };
171 | /* End PBXSourcesBuildPhase section */
172 |
173 | /* Begin XCBuildConfiguration section */
174 | E36FB9542609BA45004272D9 /* Debug */ = {
175 | isa = XCBuildConfiguration;
176 | buildSettings = {
177 | ALWAYS_SEARCH_USER_PATHS = NO;
178 | CLANG_ANALYZER_NONNULL = YES;
179 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
180 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
181 | CLANG_CXX_LIBRARY = "libc++";
182 | CLANG_ENABLE_MODULES = YES;
183 | CLANG_ENABLE_OBJC_ARC = YES;
184 | CLANG_ENABLE_OBJC_WEAK = YES;
185 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
186 | CLANG_WARN_BOOL_CONVERSION = YES;
187 | CLANG_WARN_COMMA = YES;
188 | CLANG_WARN_CONSTANT_CONVERSION = YES;
189 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
190 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
191 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
192 | CLANG_WARN_EMPTY_BODY = YES;
193 | CLANG_WARN_ENUM_CONVERSION = YES;
194 | CLANG_WARN_INFINITE_RECURSION = YES;
195 | CLANG_WARN_INT_CONVERSION = YES;
196 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
197 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
198 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
199 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
200 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
201 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
202 | CLANG_WARN_STRICT_PROTOTYPES = YES;
203 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
204 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
205 | CLANG_WARN_UNREACHABLE_CODE = YES;
206 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
207 | COPY_PHASE_STRIP = NO;
208 | DEAD_CODE_STRIPPING = YES;
209 | DEBUG_INFORMATION_FORMAT = dwarf;
210 | ENABLE_STRICT_OBJC_MSGSEND = YES;
211 | ENABLE_TESTABILITY = YES;
212 | GCC_C_LANGUAGE_STANDARD = gnu11;
213 | GCC_DYNAMIC_NO_PIC = NO;
214 | GCC_NO_COMMON_BLOCKS = YES;
215 | GCC_OPTIMIZATION_LEVEL = 0;
216 | GCC_PREPROCESSOR_DEFINITIONS = (
217 | "DEBUG=1",
218 | "$(inherited)",
219 | );
220 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
221 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
222 | GCC_WARN_UNDECLARED_SELECTOR = YES;
223 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
224 | GCC_WARN_UNUSED_FUNCTION = YES;
225 | GCC_WARN_UNUSED_VARIABLE = YES;
226 | MACOSX_DEPLOYMENT_TARGET = 12.3;
227 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
228 | MTL_FAST_MATH = YES;
229 | ONLY_ACTIVE_ARCH = YES;
230 | SDKROOT = macosx;
231 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
232 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
233 | };
234 | name = Debug;
235 | };
236 | E36FB9552609BA45004272D9 /* Release */ = {
237 | isa = XCBuildConfiguration;
238 | buildSettings = {
239 | ALWAYS_SEARCH_USER_PATHS = NO;
240 | CLANG_ANALYZER_NONNULL = YES;
241 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
242 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
243 | CLANG_CXX_LIBRARY = "libc++";
244 | CLANG_ENABLE_MODULES = YES;
245 | CLANG_ENABLE_OBJC_ARC = YES;
246 | CLANG_ENABLE_OBJC_WEAK = YES;
247 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
248 | CLANG_WARN_BOOL_CONVERSION = YES;
249 | CLANG_WARN_COMMA = YES;
250 | CLANG_WARN_CONSTANT_CONVERSION = YES;
251 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
252 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
253 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
254 | CLANG_WARN_EMPTY_BODY = YES;
255 | CLANG_WARN_ENUM_CONVERSION = YES;
256 | CLANG_WARN_INFINITE_RECURSION = YES;
257 | CLANG_WARN_INT_CONVERSION = YES;
258 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
259 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
260 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
261 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
262 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
263 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
264 | CLANG_WARN_STRICT_PROTOTYPES = YES;
265 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
266 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
267 | CLANG_WARN_UNREACHABLE_CODE = YES;
268 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
269 | COPY_PHASE_STRIP = NO;
270 | DEAD_CODE_STRIPPING = YES;
271 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
272 | ENABLE_NS_ASSERTIONS = NO;
273 | ENABLE_STRICT_OBJC_MSGSEND = YES;
274 | GCC_C_LANGUAGE_STANDARD = gnu11;
275 | GCC_NO_COMMON_BLOCKS = YES;
276 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
277 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
278 | GCC_WARN_UNDECLARED_SELECTOR = YES;
279 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
280 | GCC_WARN_UNUSED_FUNCTION = YES;
281 | GCC_WARN_UNUSED_VARIABLE = YES;
282 | MACOSX_DEPLOYMENT_TARGET = 12.3;
283 | MTL_ENABLE_DEBUG_INFO = NO;
284 | MTL_FAST_MATH = YES;
285 | SDKROOT = macosx;
286 | SWIFT_COMPILATION_MODE = wholemodule;
287 | SWIFT_OPTIMIZATION_LEVEL = "-O";
288 | };
289 | name = Release;
290 | };
291 | E36FB9572609BA45004272D9 /* Debug */ = {
292 | isa = XCBuildConfiguration;
293 | buildSettings = {
294 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
295 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
296 | CODE_SIGN_ENTITLEMENTS = KeyboardShortcutsExample/KeyboardShortcutsExample.entitlements;
297 | CODE_SIGN_IDENTITY = "-";
298 | CODE_SIGN_STYLE = Automatic;
299 | COMBINE_HIDPI_IMAGES = YES;
300 | CURRENT_PROJECT_VERSION = 1;
301 | DEAD_CODE_STRIPPING = YES;
302 | DEVELOPMENT_ASSET_PATHS = "\"KeyboardShortcutsExample/Preview Content\"";
303 | DEVELOPMENT_TEAM = "";
304 | ENABLE_HARDENED_RUNTIME = YES;
305 | ENABLE_PREVIEWS = YES;
306 | INFOPLIST_FILE = KeyboardShortcutsExample/Info.plist;
307 | LD_RUNPATH_SEARCH_PATHS = (
308 | "$(inherited)",
309 | "@executable_path/../Frameworks",
310 | );
311 | MARKETING_VERSION = 1.0.0;
312 | PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.KeyboardShortcutsExample;
313 | PRODUCT_NAME = "$(TARGET_NAME)";
314 | SWIFT_VERSION = 5.0;
315 | };
316 | name = Debug;
317 | };
318 | E36FB9582609BA45004272D9 /* Release */ = {
319 | isa = XCBuildConfiguration;
320 | buildSettings = {
321 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
322 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
323 | CODE_SIGN_ENTITLEMENTS = KeyboardShortcutsExample/KeyboardShortcutsExample.entitlements;
324 | CODE_SIGN_IDENTITY = "-";
325 | CODE_SIGN_STYLE = Automatic;
326 | COMBINE_HIDPI_IMAGES = YES;
327 | CURRENT_PROJECT_VERSION = 1;
328 | DEAD_CODE_STRIPPING = YES;
329 | DEVELOPMENT_ASSET_PATHS = "\"KeyboardShortcutsExample/Preview Content\"";
330 | DEVELOPMENT_TEAM = "";
331 | ENABLE_HARDENED_RUNTIME = YES;
332 | ENABLE_PREVIEWS = YES;
333 | INFOPLIST_FILE = KeyboardShortcutsExample/Info.plist;
334 | LD_RUNPATH_SEARCH_PATHS = (
335 | "$(inherited)",
336 | "@executable_path/../Frameworks",
337 | );
338 | MARKETING_VERSION = 1.0.0;
339 | PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.KeyboardShortcutsExample;
340 | PRODUCT_NAME = "$(TARGET_NAME)";
341 | SWIFT_VERSION = 5.0;
342 | };
343 | name = Release;
344 | };
345 | /* End XCBuildConfiguration section */
346 |
347 | /* Begin XCConfigurationList section */
348 | E36FB9412609BA43004272D9 /* Build configuration list for PBXProject "KeyboardShortcutsExample" */ = {
349 | isa = XCConfigurationList;
350 | buildConfigurations = (
351 | E36FB9542609BA45004272D9 /* Debug */,
352 | E36FB9552609BA45004272D9 /* Release */,
353 | );
354 | defaultConfigurationIsVisible = 0;
355 | defaultConfigurationName = Release;
356 | };
357 | E36FB9562609BA45004272D9 /* Build configuration list for PBXNativeTarget "KeyboardShortcutsExample" */ = {
358 | isa = XCConfigurationList;
359 | buildConfigurations = (
360 | E36FB9572609BA45004272D9 /* Debug */,
361 | E36FB9582609BA45004272D9 /* Release */,
362 | );
363 | defaultConfigurationIsVisible = 0;
364 | defaultConfigurationName = Release;
365 | };
366 | /* End XCConfigurationList section */
367 |
368 | /* Begin XCSwiftPackageProductDependency section */
369 | E33F1EFB26F3B89C00ACEB0F /* KeyboardShortcuts */ = {
370 | isa = XCSwiftPackageProductDependency;
371 | productName = KeyboardShortcuts;
372 | };
373 | /* End XCSwiftPackageProductDependency section */
374 | };
375 | rootObject = E36FB93E2609BA43004272D9 /* Project object */;
376 | }
377 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/App.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct AppMain: App {
5 | @StateObject private var state = AppState()
6 |
7 | var body: some Scene {
8 | WindowGroup {
9 | MainScreen()
10 | .task {
11 | state.createMenus()
12 | }
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/AppState.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @MainActor
4 | final class AppState: ObservableObject {
5 | func createMenus() {
6 | let testMenuItem = NSMenuItem()
7 | NSApp.mainMenu?.addItem(testMenuItem)
8 |
9 | let testMenu = NSMenu()
10 | testMenu.title = "Test"
11 | testMenuItem.submenu = testMenu
12 |
13 | testMenu.addCallbackItem("Shortcut 1") { [weak self] in
14 | self?.alert(1)
15 | }
16 | .setShortcut(for: .testShortcut1)
17 |
18 | testMenu.addCallbackItem("Shortcut 2") { [weak self] in
19 | self?.alert(2)
20 | }
21 | .setShortcut(for: .testShortcut2)
22 |
23 | testMenu.addCallbackItem("Shortcut 3") { [weak self] in
24 | self?.alert(3)
25 | }
26 | .setShortcut(for: .testShortcut3)
27 |
28 | testMenu.addCallbackItem("Shortcut 4") { [weak self] in
29 | self?.alert(4)
30 | }
31 | .setShortcut(for: .testShortcut4)
32 | }
33 |
34 | private func alert(_ number: Int) {
35 | let alert = NSAlert()
36 | alert.messageText = "Shortcut \(number) menu item action triggered!"
37 | alert.runModal()
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/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 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/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 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | $(MARKETING_VERSION)
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 | LSMinimumSystemVersion
22 | $(MACOSX_DEPLOYMENT_TARGET)
23 |
24 |
25 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/KeyboardShortcutsExample.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/MainScreen.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import KeyboardShortcuts
3 |
4 | extension KeyboardShortcuts.Name {
5 | static let testShortcut1 = Self("testShortcut1")
6 | static let testShortcut2 = Self("testShortcut2")
7 | static let testShortcut3 = Self("testShortcut3")
8 | static let testShortcut4 = Self("testShortcut4")
9 | }
10 |
11 | private struct DynamicShortcutRecorder: View {
12 | @FocusState private var isFocused: Bool
13 |
14 | @Binding var name: KeyboardShortcuts.Name
15 | @Binding var isPressed: Bool
16 |
17 | var body: some View {
18 | HStack(alignment: .firstTextBaseline) {
19 | KeyboardShortcuts.Recorder(for: name)
20 | .focused($isFocused)
21 | .padding(.trailing, 10)
22 | Text("Pressed? \(isPressed ? "👍" : "👎")")
23 | .frame(width: 100, alignment: .leading)
24 | }
25 | .onChange(of: name) { _ in
26 | isFocused = true
27 | }
28 | }
29 | }
30 |
31 | private struct DynamicShortcut: View {
32 | private struct Shortcut: Hashable, Identifiable {
33 | var id: String
34 | var name: KeyboardShortcuts.Name
35 | }
36 |
37 | private static let shortcuts = [
38 | Shortcut(id: "Shortcut3", name: .testShortcut3),
39 | Shortcut(id: "Shortcut4", name: .testShortcut4)
40 | ]
41 |
42 | @State private var shortcut = Self.shortcuts.first!
43 | @State private var isPressed = false
44 |
45 | var body: some View {
46 | VStack {
47 | Text("Dynamic Recorder")
48 | .bold()
49 | .padding(.bottom, 10)
50 | VStack {
51 | Picker("Select shortcut:", selection: $shortcut) {
52 | ForEach(Self.shortcuts) {
53 | Text($0.id)
54 | .tag($0)
55 | }
56 | }
57 | Divider()
58 | DynamicShortcutRecorder(name: $shortcut.name, isPressed: $isPressed)
59 | }
60 | }
61 | .frame(maxWidth: 300)
62 | .padding()
63 | .padding(.bottom, 20)
64 | .onChange(of: shortcut) { [oldValue = shortcut] in
65 | onShortcutChange(oldValue: oldValue, newValue: $0)
66 | }
67 | }
68 |
69 | private func onShortcutChange(oldValue: Shortcut, newValue: Shortcut) {
70 | KeyboardShortcuts.disable(oldValue.name)
71 |
72 | KeyboardShortcuts.onKeyDown(for: newValue.name) {
73 | isPressed = true
74 | }
75 |
76 | KeyboardShortcuts.onKeyUp(for: newValue.name) {
77 | isPressed = false
78 | }
79 | }
80 | }
81 |
82 | private struct DoubleShortcut: View {
83 | @State private var isPressed1 = false
84 | @State private var isPressed2 = false
85 |
86 | var body: some View {
87 | Form {
88 | KeyboardShortcuts.Recorder("Shortcut 1:", name: .testShortcut1)
89 | .overlay(alignment: .trailing) {
90 | Text("Pressed? \(isPressed1 ? "👍" : "👎")")
91 | .offset(x: 90)
92 | }
93 | KeyboardShortcuts.Recorder(for: .testShortcut2) {
94 | Text("Shortcut 2:") // Intentionally using the verbose initializer for testing.
95 | }
96 | .overlay(alignment: .trailing) {
97 | Text("Pressed? \(isPressed2 ? "👍" : "👎")")
98 | .offset(x: 90)
99 | }
100 | Spacer()
101 | Button("Reset All") {
102 | KeyboardShortcuts.reset(.testShortcut1, .testShortcut2)
103 | }
104 | }
105 | .offset(x: -40)
106 | .frame(maxWidth: 300)
107 | .padding()
108 | .padding()
109 | .onKeyboardShortcut(.testShortcut1) {
110 | isPressed1 = $0 == .keyDown
111 | }
112 | .onKeyboardShortcut(.testShortcut2, type: .keyDown) {
113 | isPressed2 = true
114 | }
115 | .task {
116 | KeyboardShortcuts.onKeyUp(for: .testShortcut2) {
117 | isPressed2 = false
118 | }
119 | }
120 | }
121 | }
122 |
123 | struct MainScreen: View {
124 | var body: some View {
125 | VStack {
126 | DoubleShortcut()
127 | Divider()
128 | DynamicShortcut()
129 | }
130 | .frame(width: 400, height: 320)
131 | }
132 | }
133 |
134 | struct MainScreen_Previews: PreviewProvider {
135 | static var previews: some View {
136 | MainScreen()
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/Utilities.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 |
4 | final class CallbackMenuItem: NSMenuItem {
5 | private static var validateCallback: ((NSMenuItem) -> Bool)?
6 |
7 | static func validate(_ callback: @escaping (NSMenuItem) -> Bool) {
8 | validateCallback = callback
9 | }
10 |
11 | private let callback: () -> Void
12 |
13 | init(
14 | _ title: String,
15 | key: String = "",
16 | keyModifiers: NSEvent.ModifierFlags? = nil,
17 | isEnabled: Bool = true,
18 | isChecked: Bool = false,
19 | isHidden: Bool = false,
20 | action: @escaping () -> Void
21 | ) {
22 | self.callback = action
23 | super.init(title: title, action: #selector(action(_:)), keyEquivalent: key)
24 | self.target = self
25 | self.isEnabled = isEnabled
26 | self.isChecked = isChecked
27 | self.isHidden = isHidden
28 |
29 | if let keyModifiers {
30 | self.keyEquivalentModifierMask = keyModifiers
31 | }
32 | }
33 |
34 | @available(*, unavailable)
35 | required init(coder decoder: NSCoder) {
36 | // swiftlint:disable:next fatal_error_message
37 | fatalError()
38 | }
39 |
40 | @objc
41 | private func action(_ sender: NSMenuItem) {
42 | callback()
43 | }
44 | }
45 |
46 | extension CallbackMenuItem: NSMenuItemValidation {
47 | func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
48 | Self.validateCallback?(menuItem) ?? true
49 | }
50 | }
51 |
52 |
53 | extension NSMenuItem {
54 | convenience init(
55 | _ title: String,
56 | action: Selector? = nil,
57 | key: String = "",
58 | keyModifiers: NSEvent.ModifierFlags? = nil,
59 | data: Any? = nil,
60 | isEnabled: Bool = true,
61 | isChecked: Bool = false,
62 | isHidden: Bool = false
63 | ) {
64 | self.init(title: title, action: action, keyEquivalent: key)
65 | self.representedObject = data
66 | self.isEnabled = isEnabled
67 | self.isChecked = isChecked
68 | self.isHidden = isHidden
69 |
70 | if let keyModifiers {
71 | self.keyEquivalentModifierMask = keyModifiers
72 | }
73 | }
74 |
75 | var isChecked: Bool {
76 | get { state == .on }
77 | set {
78 | state = newValue ? .on : .off
79 | }
80 | }
81 | }
82 |
83 |
84 | extension NSMenu {
85 | @discardableResult
86 | func addCallbackItem(
87 | _ title: String,
88 | key: String = "",
89 | keyModifiers: NSEvent.ModifierFlags? = nil,
90 | isEnabled: Bool = true,
91 | isChecked: Bool = false,
92 | isHidden: Bool = false,
93 | action: @escaping () -> Void
94 | ) -> NSMenuItem {
95 | let menuItem = CallbackMenuItem(
96 | title,
97 | key: key,
98 | keyModifiers: keyModifiers,
99 | isEnabled: isEnabled,
100 | isChecked: isChecked,
101 | isHidden: isHidden,
102 | action: action
103 | )
104 | addItem(menuItem)
105 | return menuItem
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.7
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "KeyboardShortcuts",
6 | defaultLocalization: "en",
7 | platforms: [
8 | .macOS(.v10_13)
9 | ],
10 | products: [
11 | .library(
12 | name: "KeyboardShortcuts",
13 | targets: [
14 | "KeyboardShortcuts"
15 | ]
16 | )
17 | ],
18 | targets: [
19 | .target(
20 | name: "KeyboardShortcuts"
21 | ),
22 | .testTarget(
23 | name: "KeyboardShortcutsTests",
24 | dependencies: [
25 | "KeyboardShortcuts"
26 | ]
27 | )
28 | ]
29 | )
30 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/CarbonKeyboardShortcuts.swift:
--------------------------------------------------------------------------------
1 | import Carbon.HIToolbox
2 |
3 | private func carbonKeyboardShortcutsEventHandler(eventHandlerCall: EventHandlerCallRef?, event: EventRef?, userData: UnsafeMutableRawPointer?) -> OSStatus {
4 | CarbonKeyboardShortcuts.handleEvent(event)
5 | }
6 |
7 | enum CarbonKeyboardShortcuts {
8 | private final class HotKey {
9 | let shortcut: KeyboardShortcuts.Shortcut
10 | let carbonHotKeyId: Int
11 | let carbonHotKey: EventHotKeyRef
12 | let onKeyDown: (KeyboardShortcuts.Shortcut) -> Void
13 | let onKeyUp: (KeyboardShortcuts.Shortcut) -> Void
14 |
15 | init(
16 | shortcut: KeyboardShortcuts.Shortcut,
17 | carbonHotKeyID: Int,
18 | carbonHotKey: EventHotKeyRef,
19 | onKeyDown: @escaping (KeyboardShortcuts.Shortcut) -> Void,
20 | onKeyUp: @escaping (KeyboardShortcuts.Shortcut) -> Void
21 | ) {
22 | self.shortcut = shortcut
23 | self.carbonHotKeyId = carbonHotKeyID
24 | self.carbonHotKey = carbonHotKey
25 | self.onKeyDown = onKeyDown
26 | self.onKeyUp = onKeyUp
27 | }
28 | }
29 |
30 | private static var hotKeys = [Int: HotKey]()
31 |
32 | // `SSKS` is just short for `Sindre Sorhus Keyboard Shortcuts`.
33 | // Using an integer now that `UTGetOSTypeFromString("SSKS" as CFString)` is deprecated.
34 | // swiftlint:disable:next number_separator
35 | private static let hotKeySignature: UInt32 = 1397967699 // OSType => "SSKS"
36 |
37 | private static var hotKeyId = 0
38 | private static var eventHandler: EventHandlerRef?
39 |
40 | private static func setUpEventHandlerIfNeeded() {
41 | guard
42 | eventHandler == nil,
43 | let dispatcher = GetEventDispatcherTarget()
44 | else {
45 | return
46 | }
47 |
48 | let eventSpecs = [
49 | EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
50 | EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
51 | ]
52 |
53 | InstallEventHandler(
54 | dispatcher,
55 | carbonKeyboardShortcutsEventHandler,
56 | eventSpecs.count,
57 | eventSpecs,
58 | nil,
59 | &eventHandler
60 | )
61 | }
62 |
63 | static func register(
64 | _ shortcut: KeyboardShortcuts.Shortcut,
65 | onKeyDown: @escaping (KeyboardShortcuts.Shortcut) -> Void,
66 | onKeyUp: @escaping (KeyboardShortcuts.Shortcut) -> Void
67 | ) {
68 | hotKeyId += 1
69 |
70 | var eventHotKey: EventHotKeyRef?
71 | let registerError = RegisterEventHotKey(
72 | UInt32(shortcut.carbonKeyCode),
73 | UInt32(shortcut.carbonModifiers),
74 | EventHotKeyID(signature: hotKeySignature, id: UInt32(hotKeyId)),
75 | GetEventDispatcherTarget(),
76 | 0,
77 | &eventHotKey
78 | )
79 |
80 | guard
81 | registerError == noErr,
82 | let carbonHotKey = eventHotKey
83 | else {
84 | return
85 | }
86 |
87 | hotKeys[hotKeyId] = HotKey(
88 | shortcut: shortcut,
89 | carbonHotKeyID: hotKeyId,
90 | carbonHotKey: carbonHotKey,
91 | onKeyDown: onKeyDown,
92 | onKeyUp: onKeyUp
93 | )
94 |
95 | setUpEventHandlerIfNeeded()
96 | }
97 |
98 | private static func unregisterHotKey(_ hotKey: HotKey) {
99 | UnregisterEventHotKey(hotKey.carbonHotKey)
100 | hotKeys.removeValue(forKey: hotKey.carbonHotKeyId)
101 | }
102 |
103 | static func unregister(_ shortcut: KeyboardShortcuts.Shortcut) {
104 | for hotKey in hotKeys.values where hotKey.shortcut == shortcut {
105 | unregisterHotKey(hotKey)
106 | }
107 | }
108 |
109 | static func unregisterAll() {
110 | for hotKey in hotKeys.values {
111 | unregisterHotKey(hotKey)
112 | }
113 | }
114 |
115 | fileprivate static func handleEvent(_ event: EventRef?) -> OSStatus {
116 | guard let event else {
117 | return OSStatus(eventNotHandledErr)
118 | }
119 |
120 | var eventHotKeyId = EventHotKeyID()
121 | let error = GetEventParameter(
122 | event,
123 | UInt32(kEventParamDirectObject),
124 | UInt32(typeEventHotKeyID),
125 | nil,
126 | MemoryLayout.size,
127 | nil,
128 | &eventHotKeyId
129 | )
130 |
131 | guard error == noErr else {
132 | return error
133 | }
134 |
135 | guard
136 | eventHotKeyId.signature == hotKeySignature,
137 | let hotKey = hotKeys[Int(eventHotKeyId.id)]
138 | else {
139 | return OSStatus(eventNotHandledErr)
140 | }
141 |
142 | switch Int(GetEventKind(event)) {
143 | case kEventHotKeyPressed:
144 | hotKey.onKeyDown(hotKey.shortcut)
145 | return noErr
146 | case kEventHotKeyReleased:
147 | hotKey.onKeyUp(hotKey.shortcut)
148 | return noErr
149 | default:
150 | break
151 | }
152 |
153 | return OSStatus(eventNotHandledErr)
154 | }
155 | }
156 |
157 | extension CarbonKeyboardShortcuts {
158 | static var system: [KeyboardShortcuts.Shortcut] {
159 | var shortcutsUnmanaged: Unmanaged?
160 | guard
161 | CopySymbolicHotKeys(&shortcutsUnmanaged) == noErr,
162 | let shortcuts = shortcutsUnmanaged?.takeRetainedValue() as? [[String: Any]]
163 | else {
164 | assertionFailure("Could not get system keyboard shortcuts")
165 | return []
166 | }
167 |
168 | return shortcuts.compactMap {
169 | guard
170 | ($0[kHISymbolicHotKeyEnabled] as? Bool) == true,
171 | let carbonKeyCode = $0[kHISymbolicHotKeyCode] as? Int,
172 | let carbonModifiers = $0[kHISymbolicHotKeyModifiers] as? Int
173 | else {
174 | return nil
175 | }
176 |
177 | return KeyboardShortcuts.Shortcut(
178 | carbonKeyCode: carbonKeyCode,
179 | carbonModifiers: carbonModifiers
180 | )
181 | }
182 | }
183 | }
184 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Key.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 | import Carbon.HIToolbox
3 |
4 | extension KeyboardShortcuts {
5 | // swiftlint:disable identifier_name
6 | /**
7 | Represents a key on the keyboard.
8 | */
9 | public struct Key: Hashable, RawRepresentable {
10 | // MARK: Letters
11 |
12 | public static let a = Self(kVK_ANSI_A)
13 | public static let b = Self(kVK_ANSI_B)
14 | public static let c = Self(kVK_ANSI_C)
15 | public static let d = Self(kVK_ANSI_D)
16 | public static let e = Self(kVK_ANSI_E)
17 | public static let f = Self(kVK_ANSI_F)
18 | public static let g = Self(kVK_ANSI_G)
19 | public static let h = Self(kVK_ANSI_H)
20 | public static let i = Self(kVK_ANSI_I)
21 | public static let j = Self(kVK_ANSI_J)
22 | public static let k = Self(kVK_ANSI_K)
23 | public static let l = Self(kVK_ANSI_L)
24 | public static let m = Self(kVK_ANSI_M)
25 | public static let n = Self(kVK_ANSI_N)
26 | public static let o = Self(kVK_ANSI_O)
27 | public static let p = Self(kVK_ANSI_P)
28 | public static let q = Self(kVK_ANSI_Q)
29 | public static let r = Self(kVK_ANSI_R)
30 | public static let s = Self(kVK_ANSI_S)
31 | public static let t = Self(kVK_ANSI_T)
32 | public static let u = Self(kVK_ANSI_U)
33 | public static let v = Self(kVK_ANSI_V)
34 | public static let w = Self(kVK_ANSI_W)
35 | public static let x = Self(kVK_ANSI_X)
36 | public static let y = Self(kVK_ANSI_Y)
37 | public static let z = Self(kVK_ANSI_Z)
38 | // swiftlint:enable identifier_name
39 |
40 | // MARK: Numbers
41 |
42 | public static let zero = Self(kVK_ANSI_0)
43 | public static let one = Self(kVK_ANSI_1)
44 | public static let two = Self(kVK_ANSI_2)
45 | public static let three = Self(kVK_ANSI_3)
46 | public static let four = Self(kVK_ANSI_4)
47 | public static let five = Self(kVK_ANSI_5)
48 | public static let six = Self(kVK_ANSI_6)
49 | public static let seven = Self(kVK_ANSI_7)
50 | public static let eight = Self(kVK_ANSI_8)
51 | public static let nine = Self(kVK_ANSI_9)
52 |
53 | // MARK: Modifiers
54 |
55 | public static let capsLock = Self(kVK_CapsLock)
56 | public static let shift = Self(kVK_Shift)
57 | public static let function = Self(kVK_Function)
58 | public static let control = Self(kVK_Control)
59 | public static let option = Self(kVK_Option)
60 | public static let command = Self(kVK_Command)
61 | public static let rightCommand = Self(kVK_RightCommand)
62 | public static let rightOption = Self(kVK_RightOption)
63 | public static let rightControl = Self(kVK_RightControl)
64 | public static let rightShift = Self(kVK_RightShift)
65 |
66 | // MARK: Miscellaneous
67 |
68 | public static let `return` = Self(kVK_Return)
69 | public static let backslash = Self(kVK_ANSI_Backslash)
70 | public static let backtick = Self(kVK_ANSI_Grave)
71 | public static let comma = Self(kVK_ANSI_Comma)
72 | public static let equal = Self(kVK_ANSI_Equal)
73 | public static let minus = Self(kVK_ANSI_Minus)
74 | public static let period = Self(kVK_ANSI_Period)
75 | public static let quote = Self(kVK_ANSI_Quote)
76 | public static let semicolon = Self(kVK_ANSI_Semicolon)
77 | public static let slash = Self(kVK_ANSI_Slash)
78 | public static let space = Self(kVK_Space)
79 | public static let tab = Self(kVK_Tab)
80 | public static let leftBracket = Self(kVK_ANSI_LeftBracket)
81 | public static let rightBracket = Self(kVK_ANSI_RightBracket)
82 | public static let pageUp = Self(kVK_PageUp)
83 | public static let pageDown = Self(kVK_PageDown)
84 | public static let home = Self(kVK_Home)
85 | public static let end = Self(kVK_End)
86 | public static let upArrow = Self(kVK_UpArrow)
87 | public static let rightArrow = Self(kVK_RightArrow)
88 | public static let downArrow = Self(kVK_DownArrow)
89 | public static let leftArrow = Self(kVK_LeftArrow)
90 | public static let escape = Self(kVK_Escape)
91 | public static let delete = Self(kVK_Delete)
92 | public static let deleteForward = Self(kVK_ForwardDelete)
93 | public static let help = Self(kVK_Help)
94 | public static let mute = Self(kVK_Mute)
95 | public static let volumeUp = Self(kVK_VolumeUp)
96 | public static let volumeDown = Self(kVK_VolumeDown)
97 |
98 | // MARK: Function
99 |
100 | public static let f1 = Self(kVK_F1)
101 | public static let f2 = Self(kVK_F2)
102 | public static let f3 = Self(kVK_F3)
103 | public static let f4 = Self(kVK_F4)
104 | public static let f5 = Self(kVK_F5)
105 | public static let f6 = Self(kVK_F6)
106 | public static let f7 = Self(kVK_F7)
107 | public static let f8 = Self(kVK_F8)
108 | public static let f9 = Self(kVK_F9)
109 | public static let f10 = Self(kVK_F10)
110 | public static let f11 = Self(kVK_F11)
111 | public static let f12 = Self(kVK_F12)
112 | public static let f13 = Self(kVK_F13)
113 | public static let f14 = Self(kVK_F14)
114 | public static let f15 = Self(kVK_F15)
115 | public static let f16 = Self(kVK_F16)
116 | public static let f17 = Self(kVK_F17)
117 | public static let f18 = Self(kVK_F18)
118 | public static let f19 = Self(kVK_F19)
119 | public static let f20 = Self(kVK_F20)
120 |
121 | // MARK: Keypad
122 |
123 | public static let keypad0 = Self(kVK_ANSI_Keypad0)
124 | public static let keypad1 = Self(kVK_ANSI_Keypad1)
125 | public static let keypad2 = Self(kVK_ANSI_Keypad2)
126 | public static let keypad3 = Self(kVK_ANSI_Keypad3)
127 | public static let keypad4 = Self(kVK_ANSI_Keypad4)
128 | public static let keypad5 = Self(kVK_ANSI_Keypad5)
129 | public static let keypad6 = Self(kVK_ANSI_Keypad6)
130 | public static let keypad7 = Self(kVK_ANSI_Keypad7)
131 | public static let keypad8 = Self(kVK_ANSI_Keypad8)
132 | public static let keypad9 = Self(kVK_ANSI_Keypad9)
133 | public static let keypadClear = Self(kVK_ANSI_KeypadClear)
134 | public static let keypadDecimal = Self(kVK_ANSI_KeypadDecimal)
135 | public static let keypadDivide = Self(kVK_ANSI_KeypadDivide)
136 | public static let keypadEnter = Self(kVK_ANSI_KeypadEnter)
137 | public static let keypadEquals = Self(kVK_ANSI_KeypadEquals)
138 | public static let keypadMinus = Self(kVK_ANSI_KeypadMinus)
139 | public static let keypadMultiply = Self(kVK_ANSI_KeypadMultiply)
140 | public static let keypadPlus = Self(kVK_ANSI_KeypadPlus)
141 |
142 | // MARK: Properties
143 |
144 | /**
145 | The raw key code.
146 | */
147 | public let rawValue: Int
148 |
149 | // MARK: Initializers
150 |
151 | /**
152 | Create a `Key` from a key code.
153 | */
154 | public init(rawValue: Int) {
155 | self.rawValue = rawValue
156 | }
157 |
158 | private init(_ value: Int) {
159 | self.init(rawValue: value)
160 | }
161 | }
162 | }
163 |
164 | extension KeyboardShortcuts.Key {
165 | /**
166 | All the function keys.
167 | */
168 | static let functionKeys: Set = [
169 | .f1,
170 | .f2,
171 | .f3,
172 | .f4,
173 | .f5,
174 | .f6,
175 | .f7,
176 | .f8,
177 | .f9,
178 | .f10,
179 | .f11,
180 | .f12,
181 | .f13,
182 | .f14,
183 | .f15,
184 | .f16,
185 | .f17,
186 | .f18,
187 | .f19,
188 | .f20
189 | ]
190 |
191 | /**
192 | Returns true if the key is a function key. For example, `F1`.
193 | */
194 | var isFunctionKey: Bool { Self.functionKeys.contains(self) }
195 | }
196 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/KeyboardShortcuts.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 |
3 | /**
4 | Global keyboard shortcuts for your macOS app.
5 | */
6 | public enum KeyboardShortcuts {
7 | private static var registeredShortcuts = Set()
8 |
9 | private static var legacyKeyDownHandlers = [Name: [() -> Void]]()
10 | private static var legacyKeyUpHandlers = [Name: [() -> Void]]()
11 |
12 | private static var streamKeyDownHandlers = [Name: [UUID: () -> Void]]()
13 | private static var streamKeyUpHandlers = [Name: [UUID: () -> Void]]()
14 |
15 | private static var shortcutsForLegacyHandlers: Set {
16 | let shortcuts = [legacyKeyDownHandlers.keys, legacyKeyUpHandlers.keys]
17 | .flatMap { $0 }
18 | .compactMap(\.shortcut)
19 |
20 | return Set(shortcuts)
21 | }
22 |
23 | private static var shortcutsForStreamHandlers: Set {
24 | let shortcuts = [streamKeyDownHandlers.keys, streamKeyUpHandlers.keys]
25 | .flatMap { $0 }
26 | .compactMap(\.shortcut)
27 |
28 | return Set(shortcuts)
29 | }
30 |
31 | private static var shortcutsForHandlers: Set {
32 | shortcutsForLegacyHandlers.union(shortcutsForStreamHandlers)
33 | }
34 |
35 | /**
36 | When `true`, event handlers will not be called for registered keyboard shortcuts.
37 | */
38 | static var isPaused = false
39 |
40 | private static func register(_ shortcut: Shortcut) {
41 | guard !registeredShortcuts.contains(shortcut) else {
42 | return
43 | }
44 |
45 | CarbonKeyboardShortcuts.register(
46 | shortcut,
47 | onKeyDown: handleOnKeyDown,
48 | onKeyUp: handleOnKeyUp
49 | )
50 |
51 | registeredShortcuts.insert(shortcut)
52 | }
53 |
54 | /**
55 | Register the shortcut for the given name if it has a shortcut.
56 | */
57 | private static func registerShortcutIfNeeded(for name: Name) {
58 | guard let shortcut = getShortcut(for: name) else {
59 | return
60 | }
61 |
62 | register(shortcut)
63 | }
64 |
65 | private static func unregister(_ shortcut: Shortcut) {
66 | CarbonKeyboardShortcuts.unregister(shortcut)
67 | registeredShortcuts.remove(shortcut)
68 | }
69 |
70 | /**
71 | Unregister the given shortcut if it has no handlers.
72 | */
73 | private static func unregisterIfNeeded(_ shortcut: Shortcut) {
74 | guard !shortcutsForHandlers.contains(shortcut) else {
75 | return
76 | }
77 |
78 | unregister(shortcut)
79 | }
80 |
81 | /**
82 | Unregister the shortcut for the given name if it has no handlers.
83 | */
84 | private static func unregisterShortcutIfNeeded(for name: Name) {
85 | guard let shortcut = name.shortcut else {
86 | return
87 | }
88 |
89 | unregisterIfNeeded(shortcut)
90 | }
91 |
92 | private static func unregisterAll() {
93 | CarbonKeyboardShortcuts.unregisterAll()
94 | registeredShortcuts.removeAll()
95 |
96 | // TODO: Should remove user defaults too.
97 | }
98 |
99 | /**
100 | Remove all handlers receiving keyboard shortcuts events.
101 |
102 | This can be used to reset the handlers before re-creating them to avoid having multiple handlers for the same shortcut.
103 |
104 | - Note: This method does not affect listeners using `.on()`.
105 | */
106 | public static func removeAllHandlers() {
107 | let shortcutsToUnregister = shortcutsForLegacyHandlers.subtracting(shortcutsForStreamHandlers)
108 |
109 | for shortcut in shortcutsToUnregister {
110 | unregister(shortcut)
111 | }
112 |
113 | legacyKeyDownHandlers = [:]
114 | legacyKeyUpHandlers = [:]
115 | }
116 |
117 | // TODO: Also add `.isEnabled(_ name: Name)`.
118 | /**
119 | Disable a keyboard shortcut.
120 | */
121 | public static func disable(_ name: Name) {
122 | guard let shortcut = getShortcut(for: name) else {
123 | return
124 | }
125 |
126 | unregister(shortcut)
127 | }
128 |
129 | /**
130 | Enable a disabled keyboard shortcut.
131 | */
132 | public static func enable(_ name: Name) {
133 | guard let shortcut = getShortcut(for: name) else {
134 | return
135 | }
136 |
137 | register(shortcut)
138 | }
139 |
140 | /**
141 | Reset the keyboard shortcut for one or more names.
142 |
143 | If the `Name` has a default shortcut, it will reset to that.
144 |
145 | ```swift
146 | import SwiftUI
147 | import KeyboardShortcuts
148 |
149 | struct SettingsScreen: View {
150 | var body: some View {
151 | VStack {
152 | // …
153 | Button("Reset All") {
154 | KeyboardShortcuts.reset(
155 | .toggleUnicornMode,
156 | .showRainbow
157 | )
158 | }
159 | }
160 | }
161 | }
162 | ```
163 | */
164 | public static func reset(_ names: Name...) {
165 | reset(names)
166 | }
167 |
168 | /**
169 | Reset the keyboard shortcut for one or more names.
170 |
171 | If the `Name` has a default shortcut, it will reset to that.
172 |
173 | - Note: This overload exists as Swift doesn't support splatting.
174 |
175 | ```swift
176 | import SwiftUI
177 | import KeyboardShortcuts
178 |
179 | struct SettingsScreen: View {
180 | var body: some View {
181 | VStack {
182 | // …
183 | Button("Reset All") {
184 | KeyboardShortcuts.reset(
185 | .toggleUnicornMode,
186 | .showRainbow
187 | )
188 | }
189 | }
190 | }
191 | }
192 | ```
193 | */
194 | public static func reset(_ names: [Name]) {
195 | for name in names {
196 | setShortcut(name.defaultShortcut, for: name)
197 | }
198 | }
199 |
200 | /**
201 | Set the keyboard shortcut for a name.
202 |
203 | Setting it to `nil` removes the shortcut, even if the `Name` has a default shortcut defined. Use `.reset()` if you want it to respect the default shortcut.
204 |
205 | You would usually not need this as the user would be the one setting the shortcut in a settings user-interface, but it can be useful when, for example, migrating from a different keyboard shortcuts package.
206 | */
207 | public static func setShortcut(_ shortcut: Shortcut?, for name: Name) {
208 | if let shortcut {
209 | userDefaultsSet(name: name, shortcut: shortcut)
210 | } else {
211 | if name.defaultShortcut != nil {
212 | userDefaultsDisable(name: name)
213 | } else {
214 | userDefaultsRemove(name: name)
215 | }
216 | }
217 | }
218 |
219 | /**
220 | Get the keyboard shortcut for a name.
221 | */
222 | public static func getShortcut(for name: Name) -> Shortcut? {
223 | guard
224 | let data = UserDefaults.standard.string(forKey: userDefaultsKey(for: name))?.data(using: .utf8),
225 | let decoded = try? JSONDecoder().decode(Shortcut.self, from: data)
226 | else {
227 | return nil
228 | }
229 |
230 | return decoded
231 | }
232 |
233 | private static func handleOnKeyDown(_ shortcut: Shortcut) {
234 | guard !isPaused else {
235 | return
236 | }
237 |
238 | for (name, handlers) in legacyKeyDownHandlers {
239 | guard getShortcut(for: name) == shortcut else {
240 | continue
241 | }
242 |
243 | for handler in handlers {
244 | handler()
245 | }
246 | }
247 |
248 | for (name, handlers) in streamKeyDownHandlers {
249 | guard getShortcut(for: name) == shortcut else {
250 | continue
251 | }
252 |
253 | for handler in handlers.values {
254 | handler()
255 | }
256 | }
257 | }
258 |
259 | private static func handleOnKeyUp(_ shortcut: Shortcut) {
260 | guard !isPaused else {
261 | return
262 | }
263 |
264 | for (name, handlers) in legacyKeyUpHandlers {
265 | guard getShortcut(for: name) == shortcut else {
266 | continue
267 | }
268 |
269 | for handler in handlers {
270 | handler()
271 | }
272 | }
273 |
274 | for (name, handlers) in streamKeyUpHandlers {
275 | guard getShortcut(for: name) == shortcut else {
276 | continue
277 | }
278 |
279 | for handler in handlers.values {
280 | handler()
281 | }
282 | }
283 | }
284 |
285 | /**
286 | Listen to the keyboard shortcut with the given name being pressed.
287 |
288 | You can register multiple listeners.
289 |
290 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
291 |
292 | ```swift
293 | import Cocoa
294 | import KeyboardShortcuts
295 |
296 | @main
297 | final class AppDelegate: NSObject, NSApplicationDelegate {
298 | func applicationDidFinishLaunching(_ notification: Notification) {
299 | KeyboardShortcuts.onKeyDown(for: .toggleUnicornMode) { [self] in
300 | isUnicornMode.toggle()
301 | }
302 | }
303 | }
304 | ```
305 | */
306 | public static func onKeyDown(for name: Name, action: @escaping () -> Void) {
307 | legacyKeyDownHandlers[name, default: []].append(action)
308 | registerShortcutIfNeeded(for: name)
309 | }
310 |
311 | /**
312 | Listen to the keyboard shortcut with the given name being pressed.
313 |
314 | You can register multiple listeners.
315 |
316 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
317 |
318 | ```swift
319 | import Cocoa
320 | import KeyboardShortcuts
321 |
322 | @main
323 | final class AppDelegate: NSObject, NSApplicationDelegate {
324 | func applicationDidFinishLaunching(_ notification: Notification) {
325 | KeyboardShortcuts.onKeyUp(for: .toggleUnicornMode) { [self] in
326 | isUnicornMode.toggle()
327 | }
328 | }
329 | }
330 | ```
331 | */
332 | public static func onKeyUp(for name: Name, action: @escaping () -> Void) {
333 | legacyKeyUpHandlers[name, default: []].append(action)
334 | registerShortcutIfNeeded(for: name)
335 | }
336 |
337 | private static let userDefaultsPrefix = "KeyboardShortcuts_"
338 |
339 | private static func userDefaultsKey(for shortcutName: Name) -> String { "\(userDefaultsPrefix)\(shortcutName.rawValue)"
340 | }
341 |
342 | static func userDefaultsDidChange(name: Name) {
343 | // TODO: Use proper UserDefaults observation instead of this.
344 | NotificationCenter.default.post(name: .shortcutByNameDidChange, object: nil, userInfo: ["name": name])
345 | }
346 |
347 | static func userDefaultsSet(name: Name, shortcut: Shortcut) {
348 | guard let encoded = try? JSONEncoder().encode(shortcut).toString else {
349 | return
350 | }
351 |
352 | if let oldShortcut = getShortcut(for: name) {
353 | unregister(oldShortcut)
354 | }
355 |
356 | register(shortcut)
357 | UserDefaults.standard.set(encoded, forKey: userDefaultsKey(for: name))
358 | userDefaultsDidChange(name: name)
359 | }
360 |
361 | static func userDefaultsDisable(name: Name) {
362 | guard let shortcut = getShortcut(for: name) else {
363 | return
364 | }
365 |
366 | UserDefaults.standard.set(false, forKey: userDefaultsKey(for: name))
367 | unregister(shortcut)
368 | userDefaultsDidChange(name: name)
369 | }
370 |
371 | static func userDefaultsRemove(name: Name) {
372 | guard let shortcut = getShortcut(for: name) else {
373 | return
374 | }
375 |
376 | UserDefaults.standard.removeObject(forKey: userDefaultsKey(for: name))
377 | unregister(shortcut)
378 | userDefaultsDidChange(name: name)
379 | }
380 |
381 | static func userDefaultsContains(name: Name) -> Bool {
382 | UserDefaults.standard.object(forKey: userDefaultsKey(for: name)) != nil
383 | }
384 | }
385 |
386 | extension KeyboardShortcuts {
387 | @available(macOS 10.15, *)
388 | public enum EventType {
389 | case keyDown
390 | case keyUp
391 | }
392 |
393 | /**
394 | Listen to the keyboard shortcut with the given name being pressed.
395 |
396 | You can register multiple listeners.
397 |
398 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
399 |
400 | Ending the async sequence will stop the listener. For example, in the below example, the listener will stop when the view disappears.
401 |
402 | ```swift
403 | import SwiftUI
404 | import KeyboardShortcuts
405 |
406 | struct ContentView: View {
407 | @State private var isUnicornMode = false
408 |
409 | var body: some View {
410 | Text(isUnicornMode ? "🦄" : "🐴")
411 | .task {
412 | for await event in KeyboardShortcuts.events(for: .toggleUnicornMode) where event == .keyUp {
413 | isUnicornMode.toggle()
414 | }
415 | }
416 | }
417 | }
418 | ```
419 |
420 | - Note: This method is not affected by `.removeAllHandlers()`.
421 | */
422 | @available(macOS 10.15, *)
423 | public static func events(for name: Name) -> AsyncStream {
424 | AsyncStream { continuation in
425 | let id = UUID()
426 |
427 | DispatchQueue.main.async {
428 | streamKeyDownHandlers[name, default: [:]][id] = {
429 | continuation.yield(.keyDown)
430 | }
431 |
432 | streamKeyUpHandlers[name, default: [:]][id] = {
433 | continuation.yield(.keyUp)
434 | }
435 |
436 | registerShortcutIfNeeded(for: name)
437 | }
438 |
439 | continuation.onTermination = { _ in
440 | DispatchQueue.main.async {
441 | streamKeyDownHandlers[name]?[id] = nil
442 | streamKeyUpHandlers[name]?[id] = nil
443 |
444 | unregisterShortcutIfNeeded(for: name)
445 | }
446 | }
447 | }
448 | }
449 |
450 | /**
451 | Listen to keyboard shortcut events with the given name and type.
452 |
453 | You can register multiple listeners.
454 |
455 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
456 |
457 | Ending the async sequence will stop the listener. For example, in the below example, the listener will stop when the view disappears.
458 |
459 | ```swift
460 | import SwiftUI
461 | import KeyboardShortcuts
462 |
463 | struct ContentView: View {
464 | @State private var isUnicornMode = false
465 |
466 | var body: some View {
467 | Text(isUnicornMode ? "🦄" : "🐴")
468 | .task {
469 | for await event in KeyboardShortcuts.events(for: .toggleUnicornMode) where event == .keyUp {
470 | isUnicornMode.toggle()
471 | }
472 | }
473 | }
474 | }
475 | ```
476 |
477 | - Note: This method is not affected by `.removeAllHandlers()`.
478 | */
479 | @available(macOS 10.15, *)
480 | public static func events(_ type: EventType, for name: Name) -> AsyncFilterSequence> {
481 | events(for: name).filter { $0 == type }
482 | }
483 |
484 | @available(macOS 10.15, *)
485 | @available(*, deprecated, renamed: "events(_:for:)")
486 | public static func on(_ type: EventType, for name: Name) -> AsyncStream {
487 | AsyncStream { continuation in
488 | let id = UUID()
489 |
490 | switch type {
491 | case .keyDown:
492 | streamKeyDownHandlers[name, default: [:]][id] = {
493 | continuation.yield()
494 | }
495 | case .keyUp:
496 | streamKeyUpHandlers[name, default: [:]][id] = {
497 | continuation.yield()
498 | }
499 | }
500 |
501 | registerShortcutIfNeeded(for: name)
502 |
503 | continuation.onTermination = { _ in
504 | switch type {
505 | case .keyDown:
506 | streamKeyDownHandlers[name]?[id] = nil
507 | case .keyUp:
508 | streamKeyUpHandlers[name]?[id] = nil
509 | }
510 |
511 | unregisterShortcutIfNeeded(for: name)
512 | }
513 | }
514 | }
515 | }
516 |
517 | extension Notification.Name {
518 | static let shortcutByNameDidChange = Self("KeyboardShortcuts_shortcutByNameDidChange")
519 | }
520 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/ar.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "سجل اختصاراً";
2 | "press_shortcut" = "اضغط على الاختصار";
3 | "keyboard_shortcut_used_by_menu_item" = "لا يمكن استخدام اختصار لوحة المفاتيح هذا لأنه مستخدم بواسطة عنصر القائمة “%@”.";
4 | "keyboard_shortcut_used_by_system" = "لا يمكن استخدام اختصار لوحة المفاتيح هذا لأنه مستخدم مسبقاً على مستوى النظام.";
5 | "keyboard_shortcuts_can_be_changed" = "يمكن تغيير معظم اختصارات لوحة المفاتيح على مستوى النظام في “تفضيلات النظام > لوحة المفاتيح > الاختصارات ”.";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/cs.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "Přidat zkratku";
2 | "press_shortcut" = "Zadejte klávesy";
3 | "keyboard_shortcut_used_by_menu_item" = "Tuto zkratku nelze použít, protože je již využívána položkou „%@“";
4 | "keyboard_shortcut_used_by_system" = "Tuto zkratku nelze použít, protože už ji používá systém.";
5 | "keyboard_shortcuts_can_be_changed" = "Většinu systémových zkratek můžete změnit v „Nastavení systému › Klávesnice › Klávesové zkratky“.";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/de.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "Kurzbefehl aufnehmen";
2 | "press_shortcut" = "Kurzbefehl wählen…";
3 | "keyboard_shortcut_used_by_menu_item" = "Dieses Tastaturkürzel kann nicht verwendet werden, da es bereits durch den Menüpunkt „%@” belegt ist.";
4 | "keyboard_shortcut_used_by_system" = "Dieses Tastaturkürzel kann nicht verwendet werden, da es bereits systemweit verwendet wird.";
5 | "keyboard_shortcuts_can_be_changed" = "Die meisten systemweiten Tastaturkürzel können unter „Systemeinstellungen › Tastatur › Kurzbefehle“ geändert werden.";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/en.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "Record Shortcut";
2 | "press_shortcut" = "Press Shortcut";
3 | "keyboard_shortcut_used_by_menu_item" = "This keyboard shortcut cannot be used as it’s already used by the “%@” menu item.";
4 | "keyboard_shortcut_used_by_system" = "This keyboard shortcut cannot be used as it’s already a system-wide keyboard shortcut.";
5 | "keyboard_shortcuts_can_be_changed" = "Most system-wide keyboard shortcuts can be changed in “System Settings › Keyboard › Keyboard Shortcuts”.";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/es.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "Grabar atajo";
2 | "press_shortcut" = "Pulsar atajo";
3 | "keyboard_shortcut_used_by_menu_item" = "Este atajo de teclado no se puede utilizar ya que está siendo utilizado por el elemento de menú “%@”.";
4 | "keyboard_shortcut_used_by_system" = "Este atajo de teclado no se puede utilizar ya que está siendo utilizado por un atajo del sistema operativo.";
5 | "keyboard_shortcuts_can_be_changed" = "La mayoría de los atajos de teclado del sistema operativo pueden ser modificados en “Configuración del sistema › Teclado › Atajos de teclado“.";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/fr.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "Enregistrer le raccourci";
2 | "press_shortcut" = "Saisir un raccourci";
3 | "keyboard_shortcut_used_by_menu_item" = "Ce raccourci ne peut pas être utilisé, car il est déjà utilisé par le menu “%@”.";
4 | "keyboard_shortcut_used_by_system" = "Ce raccourci ne peut pas être utilisé car il s'agit d'un raccourci déjà présent dans le système.";
5 | "keyboard_shortcuts_can_be_changed" = "La plupart des raccourcis clavier de l'ensemble du système peuvent être modifiés en “Réglages du système… › Clavier › Raccourcis clavier…”.";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/hu.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "Billentyűparancs rögzítése";
2 | "press_shortcut" = "Nyomja meg a billentyűparancsot";
3 | "keyboard_shortcut_used_by_menu_item" = "Ez a billentyűparancs nem használható mert már a “%@” menü elem használja.";
4 | "keyboard_shortcut_used_by_system" = "Ez a billentyűparancs nem használható mert már egy rendszerszintü billentyűparancs.";
5 | "keyboard_shortcuts_can_be_changed" = "A legtöbb rendszerszintü billentyűparancsot a “Rendszerbeállítások › Billentyűzet › Billentyűparancsok“ menüben meg lehet változtatni";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/ja.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "キーを記録";
2 | "press_shortcut" = "キーを押してください";
3 | "keyboard_shortcut_used_by_menu_item" = "このショートカットは既に“%@”で使われており使えません。";
4 | "keyboard_shortcut_used_by_system" = "このショートカットキーは既にシステムで使われており使えません。";
5 | "keyboard_shortcuts_can_be_changed" = "システムで設定されているショートカットキーは“システム設定 › キーボード › キーボードショートカット”で変更できます。";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/nl.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "Toetscombinatie opnemen";
2 | "press_shortcut" = "Voer toetscombinatie in";
3 | "keyboard_shortcut_used_by_menu_item" = "Deze toetscombinatie kan niet worden gebruikt omdat hij al wordt gebruikt door het menu item “%@”.";
4 | "keyboard_shortcut_used_by_system" = "Deze toetscombinatie kan niet worden gebruikt omdat hij al door het systeem gebruikt wordt.";
5 | "keyboard_shortcuts_can_be_changed" = "De meeste systeem toetscombinaties kunnen onder “Systeeminstellingen… > Toetsenbord > Toetscombinaties…” veranderd worden.";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/ru.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "Добавить";
2 | "press_shortcut" = "Запись…";
3 | "keyboard_shortcut_used_by_menu_item" = "Это сочетание клавиш нельзя использовать, так как оно уже используется в пункте меню «%@».";
4 | "keyboard_shortcut_used_by_system" = "Это сочетание клавиш нельзя использовать, поскольку оно является системным.";
5 | "keyboard_shortcuts_can_be_changed" = "Большинство системных сочетаний клавиш можно изменить в «Системные настройки › Клавиатура › Сочетания клавиш».";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/zh-Hans.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "设置快捷键";
2 | "press_shortcut" = "按下快捷键";
3 | "keyboard_shortcut_used_by_menu_item" = "当前快捷键无法使用,因为它已被用作菜单项 “%@” 的快捷键。";
4 | "keyboard_shortcut_used_by_system" = "当前快捷键无法使用,因为它已被用作系统快捷键。";
5 | "keyboard_shortcuts_can_be_changed" = "可以在 “系统设置 › 键盘 › 键盘快捷键” 中更改大多数系统快捷键。";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/zh-TW.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "設定快速鍵";
2 | "press_shortcut" = "按下快速鍵";
3 | "keyboard_shortcut_used_by_menu_item" = "此快速鍵無法使用,因為它已被選單項目「%@」使用。";
4 | "keyboard_shortcut_used_by_system" = "此快速鍵無法使用,因為它已被系統使用。";
5 | "keyboard_shortcuts_can_be_changed" = "可以在「系統設定 › 鍵盤 › 鍵盤快速鍵」中更改大多數的系統快速鍵。";
6 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/NSMenuItem++.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 |
3 | extension NSMenuItem {
4 | private enum AssociatedKeys {
5 | static let observer = ObjectAssociation()
6 | }
7 |
8 | private func clearShortcut() {
9 | keyEquivalent = ""
10 | keyEquivalentModifierMask = []
11 |
12 | if #available(macOS 12, *) {
13 | allowsAutomaticKeyEquivalentLocalization = true
14 | }
15 | }
16 |
17 | // TODO: Make this a getter/setter. We must first add the ability to create a `Shortcut` from a `keyEquivalent`.
18 | /**
19 | Show a recorded keyboard shortcut in a `NSMenuItem`.
20 |
21 | The menu item will automatically be kept up to date with changes to the keyboard shortcut.
22 |
23 | Pass in `nil` to clear the keyboard shortcut.
24 |
25 | This method overrides `.keyEquivalent` and `.keyEquivalentModifierMask`.
26 |
27 | ```swift
28 | import Cocoa
29 | import KeyboardShortcuts
30 |
31 | extension KeyboardShortcuts.Name {
32 | static let toggleUnicornMode = Self("toggleUnicornMode")
33 | }
34 |
35 | // … `Recorder` logic for recording the keyboard shortcut …
36 |
37 | let menuItem = NSMenuItem()
38 | menuItem.title = "Toggle Unicorn Mode"
39 | menuItem.setShortcut(for: .toggleUnicornMode)
40 | ```
41 |
42 | You can test this method in the example project. Run it, record a shortcut and then look at the “Test” menu in the app's main menu.
43 |
44 | - Important: You will have to disable the global keyboard shortcut while the menu is open, as otherwise, the keyboard events will be buffered up and triggered when the menu closes. This is because `NSMenu` puts the thread in tracking-mode, which prevents the keyboard events from being received. You can listen to whether a menu is open by implementing `NSMenuDelegate#menuWillOpen` and `NSMenuDelegate#menuDidClose`. You then use `KeyboardShortcuts.disable` and `KeyboardShortcuts.enable`.
45 | */
46 | public func setShortcut(for name: KeyboardShortcuts.Name?) {
47 | guard let name else {
48 | clearShortcut()
49 | AssociatedKeys.observer[self] = nil
50 | return
51 | }
52 |
53 | func set() {
54 | let shortcut = KeyboardShortcuts.Shortcut(name: name)
55 | setShortcut(shortcut)
56 | }
57 |
58 | set()
59 |
60 | // TODO: Use AsyncStream when targeting macOS 10.15.
61 | AssociatedKeys.observer[self] = NotificationCenter.default.addObserver(forName: .shortcutByNameDidChange, object: nil, queue: nil) { notification in
62 | guard
63 | let nameInNotification = notification.userInfo?["name"] as? KeyboardShortcuts.Name,
64 | nameInNotification == name
65 | else {
66 | return
67 | }
68 |
69 | set()
70 | }
71 | }
72 |
73 | /**
74 | Add a keyboard shortcut to a `NSMenuItem`.
75 |
76 | This method is only recommended for dynamic shortcuts. In general, it's preferred to create a static shortcut name and use `NSMenuItem.setShortcut(for:)` instead.
77 |
78 | Pass in `nil` to clear the keyboard shortcut.
79 |
80 | This method overrides `.keyEquivalent` and `.keyEquivalentModifierMask`.
81 |
82 | - Important: You will have to disable the global keyboard shortcut while the menu is open, as otherwise, the keyboard events will be buffered up and triggered when the menu closes. This is because `NSMenu` puts the thread in tracking-mode, which prevents the keyboard events from being received. You can listen to whether a menu is open by implementing `NSMenuDelegate#menuWillOpen` and `NSMenuDelegate#menuDidClose`. You then use `KeyboardShortcuts.disable` and `KeyboardShortcuts.enable`.
83 | */
84 | @_disfavoredOverload
85 | public func setShortcut(_ shortcut: KeyboardShortcuts.Shortcut?) {
86 | func set() {
87 | guard let shortcut else {
88 | clearShortcut()
89 | return
90 | }
91 |
92 | keyEquivalent = shortcut.keyEquivalent
93 | keyEquivalentModifierMask = shortcut.modifiers
94 |
95 | if #available(macOS 12, *) {
96 | allowsAutomaticKeyEquivalentLocalization = false
97 | }
98 | }
99 |
100 | // `TISCopyCurrentASCIICapableKeyboardLayoutInputSource` works on a background thread, but crashes when used in a `NSBackgroundActivityScheduler` task, so we ensure it's not run in that queue.
101 | if DispatchQueue.isCurrentQueueNSBackgroundActivitySchedulerQueue {
102 | DispatchQueue.main.async {
103 | set()
104 | }
105 | } else {
106 | set()
107 | }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Name.swift:
--------------------------------------------------------------------------------
1 | extension KeyboardShortcuts {
2 | /**
3 | The strongly-typed name of the keyboard shortcut.
4 |
5 | After registering it, you can use it in, for example, `KeyboardShortcut.Recorder` and `KeyboardShortcut.onKeyUp()`.
6 |
7 | ```swift
8 | import KeyboardShortcuts
9 |
10 | extension KeyboardShortcuts.Name {
11 | static let toggleUnicornMode = Self("toggleUnicornMode")
12 | }
13 | ```
14 | */
15 | public struct Name: Hashable {
16 | // This makes it possible to use `Shortcut` without the namespace.
17 | /// :nodoc:
18 | public typealias Shortcut = KeyboardShortcuts.Shortcut
19 |
20 | public let rawValue: String
21 | public let defaultShortcut: Shortcut?
22 |
23 | /**
24 | Get the keyboard shortcut assigned to the name.
25 | */
26 | public var shortcut: Shortcut? { KeyboardShortcuts.getShortcut(for: self) }
27 |
28 | /**
29 | - Parameter name: Name of the shortcut.
30 | - Parameter default: Optional default key combination. Do not set this unless it's essential. Users find it annoying when random apps steal their existing keyboard shortcuts. It's generally better to show a welcome screen on the first app launch that lets the user set the shortcut.
31 | */
32 | public init(_ name: String, default defaultShortcut: Shortcut? = nil) {
33 | self.rawValue = name
34 | self.defaultShortcut = defaultShortcut
35 |
36 | if
37 | let defaultShortcut,
38 | !userDefaultsContains(name: self)
39 | {
40 | setShortcut(defaultShortcut, for: self)
41 | }
42 | }
43 | }
44 | }
45 |
46 | extension KeyboardShortcuts.Name: RawRepresentable {
47 | /// :nodoc:
48 | public init?(rawValue: String) {
49 | self.init(rawValue)
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Recorder.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @available(macOS 10.15, *)
4 | extension KeyboardShortcuts {
5 | private struct _Recorder: NSViewRepresentable { // swiftlint:disable:this type_name
6 | typealias NSViewType = RecorderCocoa
7 |
8 | let name: Name
9 | let onChange: ((_ shortcut: Shortcut?) -> Void)?
10 |
11 | func makeNSView(context: Context) -> NSViewType {
12 | .init(for: name, onChange: onChange)
13 | }
14 |
15 | func updateNSView(_ nsView: NSViewType, context: Context) {
16 | nsView.shortcutName = name
17 | }
18 | }
19 |
20 | /**
21 | A SwiftUI `View` that lets the user record a keyboard shortcut.
22 |
23 | You would usually put this in your settings window.
24 |
25 | It automatically prevents choosing a keyboard shortcut that is already taken by the system or by the app's main menu by showing a user-friendly alert to the user.
26 |
27 | It takes care of storing the keyboard shortcut in `UserDefaults` for you.
28 |
29 | ```swift
30 | import SwiftUI
31 | import KeyboardShortcuts
32 |
33 | struct SettingsScreen: View {
34 | var body: some View {
35 | Form {
36 | KeyboardShortcuts.Recorder("Toggle Unicorn Mode:", name: .toggleUnicornMode)
37 | }
38 | }
39 | }
40 | ```
41 | */
42 | public struct Recorder: View { // swiftlint:disable:this type_name
43 | private let name: Name
44 | private let onChange: ((Shortcut?) -> Void)?
45 | private let hasLabel: Bool
46 | private let label: Label
47 |
48 | init(
49 | for name: Name,
50 | onChange: ((Shortcut?) -> Void)? = nil,
51 | hasLabel: Bool,
52 | @ViewBuilder label: () -> Label
53 | ) {
54 | self.name = name
55 | self.onChange = onChange
56 | self.hasLabel = hasLabel
57 | self.label = label()
58 | }
59 |
60 | public var body: some View {
61 | if hasLabel {
62 | if #available(macOS 13, *) {
63 | LabeledContent {
64 | _Recorder(
65 | name: name,
66 | onChange: onChange
67 | )
68 | } label: {
69 | label
70 | }
71 | } else {
72 | _Recorder(
73 | name: name,
74 | onChange: onChange
75 | )
76 | .formLabel {
77 | label
78 | }
79 | }
80 | } else {
81 | _Recorder(
82 | name: name,
83 | onChange: onChange
84 | )
85 | }
86 | }
87 | }
88 | }
89 |
90 | @available(macOS 10.15, *)
91 | extension KeyboardShortcuts.Recorder {
92 | /**
93 | - Parameter name: Strongly-typed keyboard shortcut name.
94 | - Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user. This can be useful when you need more control. For example, when migrating from a different keyboard shortcut solution and you need to store the keyboard shortcut somewhere yourself instead of relying on the built-in storage. However, it's strongly recommended to just rely on the built-in storage when possible.
95 | */
96 | public init(
97 | for name: KeyboardShortcuts.Name,
98 | onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil
99 | ) {
100 | self.init(
101 | for: name,
102 | onChange: onChange,
103 | hasLabel: false
104 | ) {}
105 | }
106 | }
107 |
108 | @available(macOS 10.15, *)
109 | extension KeyboardShortcuts.Recorder {
110 | /**
111 | - Parameter title: The title of the keyboard shortcut recorder, describing its purpose.
112 | - Parameter name: Strongly-typed keyboard shortcut name.
113 | - Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user. This can be useful when you need more control. For example, when migrating from a different keyboard shortcut solution and you need to store the keyboard shortcut somewhere yourself instead of relying on the built-in storage. However, it's strongly recommended to just rely on the built-in storage when possible.
114 | */
115 | public init(
116 | _ title: String,
117 | name: KeyboardShortcuts.Name,
118 | onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil
119 | ) {
120 | self.init(
121 | for: name,
122 | onChange: onChange,
123 | hasLabel: true
124 | ) {
125 | Text(title)
126 | }
127 | }
128 | }
129 |
130 | @available(macOS 10.15, *)
131 | extension KeyboardShortcuts.Recorder {
132 | /**
133 | - Parameter name: Strongly-typed keyboard shortcut name.
134 | - Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user. This can be useful when you need more control. For example, when migrating from a different keyboard shortcut solution and you need to store the keyboard shortcut somewhere yourself instead of relying on the built-in storage. However, it's strongly recommended to just rely on the built-in storage when possible.
135 | - Parameter label: A view that describes the purpose of the keyboard shortcut recorder.
136 | */
137 | public init(
138 | for name: KeyboardShortcuts.Name,
139 | onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil,
140 | @ViewBuilder label: () -> Label
141 | ) {
142 | self.init(
143 | for: name,
144 | onChange: onChange,
145 | hasLabel: true,
146 | label: label
147 | )
148 | }
149 | }
150 |
151 | @available(macOS 10.15, *)
152 | struct SwiftUI_Previews: PreviewProvider {
153 | static var previews: some View {
154 | Group {
155 | KeyboardShortcuts.Recorder(for: .init("xcodePreview"))
156 | .environment(\.locale, .init(identifier: "en"))
157 | KeyboardShortcuts.Recorder(for: .init("xcodePreview"))
158 | .environment(\.locale, .init(identifier: "zh-Hans"))
159 | KeyboardShortcuts.Recorder(for: .init("xcodePreview"))
160 | .environment(\.locale, .init(identifier: "ru"))
161 | }
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/RecorderCocoa.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 | import Carbon.HIToolbox
3 |
4 | extension KeyboardShortcuts {
5 | /**
6 | A `NSView` that lets the user record a keyboard shortcut.
7 |
8 | You would usually put this in your settings window.
9 |
10 | It automatically prevents choosing a keyboard shortcut that is already taken by the system or by the app's main menu by showing a user-friendly alert to the user.
11 |
12 | It takes care of storing the keyboard shortcut in `UserDefaults` for you.
13 |
14 | ```swift
15 | import Cocoa
16 | import KeyboardShortcuts
17 |
18 | final class SettingsViewController: NSViewController {
19 | override func loadView() {
20 | view = NSView()
21 |
22 | let recorder = KeyboardShortcuts.RecorderCocoa(for: .toggleUnicornMode)
23 | view.addSubview(recorder)
24 | }
25 | }
26 | ```
27 | */
28 | public final class RecorderCocoa: NSSearchField, NSSearchFieldDelegate {
29 | private let minimumWidth: Double = 130
30 | private var eventMonitor: LocalEventMonitor?
31 | private let onChange: ((_ shortcut: Shortcut?) -> Void)?
32 | private var observer: NSObjectProtocol?
33 | private var canBecomeKey = false
34 |
35 | /**
36 | The shortcut name for the recorder.
37 |
38 | Can be dynamically changed at any time.
39 | */
40 | public var shortcutName: Name {
41 | didSet {
42 | guard shortcutName != oldValue else {
43 | return
44 | }
45 |
46 | setStringValue(name: shortcutName)
47 |
48 | // This doesn't seem to be needed anymore, but I cannot test on older OS versions, so keeping it just in case.
49 | if #unavailable(macOS 12) {
50 | DispatchQueue.main.async { [self] in
51 | // Prevents the placeholder from being cut off.
52 | blur()
53 | }
54 | }
55 | }
56 | }
57 |
58 | /// :nodoc:
59 | override public var canBecomeKeyView: Bool { canBecomeKey }
60 |
61 | /// :nodoc:
62 | override public var intrinsicContentSize: CGSize {
63 | var size = super.intrinsicContentSize
64 | size.width = minimumWidth
65 | return size
66 | }
67 |
68 | private var cancelButton: NSButtonCell?
69 |
70 | private var showsCancelButton: Bool {
71 | get { (cell as? NSSearchFieldCell)?.cancelButtonCell != nil }
72 | set {
73 | (cell as? NSSearchFieldCell)?.cancelButtonCell = newValue ? cancelButton : nil
74 | }
75 | }
76 |
77 | /**
78 | - Parameter name: Strongly-typed keyboard shortcut name.
79 | - Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user. This can be useful when you need more control. For example, when migrating from a different keyboard shortcut solution and you need to store the keyboard shortcut somewhere yourself instead of relying on the built-in storage. However, it's strongly recommended to just rely on the built-in storage when possible.
80 | */
81 | public required init(
82 | for name: Name,
83 | onChange: ((_ shortcut: Shortcut?) -> Void)? = nil
84 | ) {
85 | self.shortcutName = name
86 | self.onChange = onChange
87 |
88 | super.init(frame: .zero)
89 | self.delegate = self
90 | self.placeholderString = "record_shortcut".localized
91 | self.alignment = .center
92 | (cell as? NSSearchFieldCell)?.searchButtonCell = nil
93 |
94 | self.wantsLayer = true
95 | setContentHuggingPriority(.defaultHigh, for: .vertical)
96 | setContentHuggingPriority(.defaultHigh, for: .horizontal)
97 |
98 | // Hide the cancel button when not showing the shortcut so the placeholder text is properly centered. Must be last.
99 | self.cancelButton = (cell as? NSSearchFieldCell)?.cancelButtonCell
100 |
101 | setStringValue(name: name)
102 |
103 | setUpEvents()
104 | }
105 |
106 | @available(*, unavailable)
107 | public required init?(coder: NSCoder) {
108 | fatalError("init(coder:) has not been implemented")
109 | }
110 |
111 | private func setStringValue(name: KeyboardShortcuts.Name) {
112 | stringValue = getShortcut(for: shortcutName).map { "\($0)" } ?? ""
113 |
114 | // If `stringValue` is empty, hide the cancel button to let the placeholder center.
115 | showsCancelButton = !stringValue.isEmpty
116 | }
117 |
118 | private func setUpEvents() {
119 | observer = NotificationCenter.default.addObserver(forName: .shortcutByNameDidChange, object: nil, queue: nil) { [weak self] notification in
120 | guard
121 | let self,
122 | let nameInNotification = notification.userInfo?["name"] as? KeyboardShortcuts.Name,
123 | nameInNotification == self.shortcutName
124 | else {
125 | return
126 | }
127 |
128 | self.setStringValue(name: nameInNotification)
129 | }
130 | }
131 |
132 | /// :nodoc:
133 | public func controlTextDidChange(_ object: Notification) {
134 | if stringValue.isEmpty {
135 | saveShortcut(nil)
136 | }
137 |
138 | showsCancelButton = !stringValue.isEmpty
139 |
140 | if stringValue.isEmpty {
141 | // Hack to ensure that the placeholder centers after the above `showsCancelButton` setter.
142 | focus()
143 | }
144 | }
145 |
146 | /// :nodoc:
147 | public func controlTextDidEndEditing(_ object: Notification) {
148 | eventMonitor = nil
149 | placeholderString = "record_shortcut".localized
150 | showsCancelButton = !stringValue.isEmpty
151 | KeyboardShortcuts.isPaused = false
152 | }
153 |
154 | /// :nodoc:
155 | override public func viewDidMoveToWindow() {
156 | guard window != nil else {
157 | return
158 | }
159 |
160 | // Prevent the control from receiving the initial focus.
161 | DispatchQueue.main.async { [self] in
162 | canBecomeKey = true
163 | }
164 | }
165 |
166 | /// :nodoc:
167 | override public func becomeFirstResponder() -> Bool {
168 | let shouldBecomeFirstResponder = super.becomeFirstResponder()
169 |
170 | guard shouldBecomeFirstResponder else {
171 | return shouldBecomeFirstResponder
172 | }
173 |
174 | placeholderString = "press_shortcut".localized
175 | showsCancelButton = !stringValue.isEmpty
176 | hideCaret()
177 | KeyboardShortcuts.isPaused = true // The position here matters.
178 |
179 | eventMonitor = LocalEventMonitor(events: [.keyDown, .leftMouseUp, .rightMouseUp]) { [weak self] event in
180 | guard let self else {
181 | return nil
182 | }
183 |
184 | let clickPoint = self.convert(event.locationInWindow, from: nil)
185 | let clickMargin = 3.0
186 |
187 | if
188 | event.type == .leftMouseUp || event.type == .rightMouseUp,
189 | !self.bounds.insetBy(dx: -clickMargin, dy: -clickMargin).contains(clickPoint)
190 | {
191 | self.blur()
192 | return event
193 | }
194 |
195 | guard event.isKeyEvent else {
196 | return nil
197 | }
198 |
199 | if
200 | event.modifiers.isEmpty,
201 | event.specialKey == .tab
202 | {
203 | self.blur()
204 |
205 | // We intentionally bubble up the event so it can focus the next responder.
206 | return event
207 | }
208 |
209 | if
210 | event.modifiers.isEmpty,
211 | event.keyCode == kVK_Escape // TODO: Make this strongly typed.
212 | {
213 | self.blur()
214 | return nil
215 | }
216 |
217 | if
218 | event.modifiers.isEmpty,
219 | event.specialKey == .delete
220 | || event.specialKey == .deleteForward
221 | || event.specialKey == .backspace
222 | {
223 | self.clear()
224 | return nil
225 | }
226 |
227 | // The “shift” key is not allowed without other modifiers or a function key, since it doesn't actually work.
228 | guard
229 | !event.modifiers.subtracting(.shift).isEmpty
230 | || event.specialKey?.isFunctionKey == true,
231 | let shortcut = Shortcut(event: event)
232 | else {
233 | NSSound.beep()
234 | return nil
235 | }
236 |
237 | if let menuItem = shortcut.takenByMainMenu {
238 | // TODO: Find a better way to make it possible to dismiss the alert by pressing "Enter". How can we make the input automatically temporarily lose focus while the alert is open?
239 | self.blur()
240 |
241 | NSAlert.showModal(
242 | for: self.window,
243 | title: String.localizedStringWithFormat("keyboard_shortcut_used_by_menu_item".localized, menuItem.title)
244 | )
245 |
246 | self.focus()
247 |
248 | return nil
249 | }
250 |
251 | guard !shortcut.isTakenBySystem else {
252 | self.blur()
253 |
254 | NSAlert.showModal(
255 | for: self.window,
256 | title: "keyboard_shortcut_used_by_system".localized,
257 | // TODO: Add button to offer to open the relevant system settings pane for the user.
258 | message: "keyboard_shortcuts_can_be_changed".localized
259 | )
260 |
261 | self.focus()
262 |
263 | return nil
264 | }
265 |
266 | self.stringValue = "\(shortcut)"
267 | self.showsCancelButton = true
268 |
269 | self.saveShortcut(shortcut)
270 | self.blur()
271 |
272 | return nil
273 | }.start()
274 |
275 | return shouldBecomeFirstResponder
276 | }
277 |
278 | private func saveShortcut(_ shortcut: Shortcut?) {
279 | setShortcut(shortcut, for: shortcutName)
280 | onChange?(shortcut)
281 | }
282 | }
283 | }
284 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Shortcut.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 | import Carbon.HIToolbox
3 |
4 | extension KeyboardShortcuts {
5 | /**
6 | A keyboard shortcut.
7 | */
8 | public struct Shortcut: Hashable, Codable {
9 | /**
10 | Carbon modifiers are not always stored as the same number.
11 |
12 | For example, the system has `⌃F2` stored with the modifiers number `135168`, but if you press the keyboard shortcut, you get `4096`.
13 | */
14 | private static func normalizeModifiers(_ carbonModifiers: Int) -> Int {
15 | NSEvent.ModifierFlags(carbon: carbonModifiers).carbon
16 | }
17 |
18 | /**
19 | The keyboard key of the shortcut.
20 | */
21 | public var key: Key? { Key(rawValue: carbonKeyCode) }
22 |
23 | /**
24 | The modifier keys of the shortcut.
25 | */
26 | public var modifiers: NSEvent.ModifierFlags { NSEvent.ModifierFlags(carbon: carbonModifiers) }
27 |
28 | /**
29 | Low-level represetation of the key.
30 |
31 | You most likely don't need this.
32 | */
33 | public let carbonKeyCode: Int
34 |
35 | /**
36 | Low-level representation of the modifier keys.
37 |
38 | You most likely don't need this.
39 | */
40 | public let carbonModifiers: Int
41 |
42 | /**
43 | Initialize from a strongly-typed key and modifiers.
44 | */
45 | public init(_ key: Key, modifiers: NSEvent.ModifierFlags = []) {
46 | self.init(
47 | carbonKeyCode: key.rawValue,
48 | carbonModifiers: modifiers.carbon
49 | )
50 | }
51 |
52 | /**
53 | Initialize from a key event.
54 | */
55 | public init?(event: NSEvent) {
56 | guard event.isKeyEvent else {
57 | return nil
58 | }
59 |
60 | self.init(
61 | carbonKeyCode: Int(event.keyCode),
62 | carbonModifiers: event.modifierFlags.carbon
63 | )
64 | }
65 |
66 | /**
67 | Initialize from a keyboard shortcut stored by `Recorder` or `RecorderCocoa`.
68 | */
69 | public init?(name: Name) {
70 | guard let shortcut = getShortcut(for: name) else {
71 | return nil
72 | }
73 |
74 | self = shortcut
75 | }
76 |
77 | /**
78 | Initialize from a key code number and modifier code.
79 |
80 | You most likely don't need this.
81 | */
82 | public init(carbonKeyCode: Int, carbonModifiers: Int = 0) {
83 | self.carbonKeyCode = carbonKeyCode
84 | self.carbonModifiers = Self.normalizeModifiers(carbonModifiers)
85 | }
86 | }
87 | }
88 |
89 | extension KeyboardShortcuts.Shortcut {
90 | /**
91 | System-defined keyboard shortcuts.
92 | */
93 | static var system: [Self] {
94 | CarbonKeyboardShortcuts.system
95 | }
96 |
97 | /**
98 | Check whether the keyboard shortcut is already taken by the system.
99 | */
100 | var isTakenBySystem: Bool {
101 | guard self != Self(.f12, modifiers: []) else {
102 | return false
103 | }
104 |
105 | return Self.system.contains(self)
106 | }
107 | }
108 |
109 | extension KeyboardShortcuts.Shortcut {
110 | /**
111 | Recursively finds a menu item in the given menu that has a matching key equivalent and modifier.
112 | */
113 | func menuItemWithMatchingShortcut(in menu: NSMenu) -> NSMenuItem? {
114 | for item in menu.items {
115 | var keyEquivalent = item.keyEquivalent
116 | var keyEquivalentModifierMask = item.keyEquivalentModifierMask
117 |
118 | if modifiers.contains(.shift), keyEquivalent.lowercased() != keyEquivalent {
119 | keyEquivalent = keyEquivalent.lowercased()
120 | keyEquivalentModifierMask.insert(.shift)
121 | }
122 |
123 | if
124 | keyToCharacter() == keyEquivalent,
125 | modifiers == keyEquivalentModifierMask
126 | {
127 | return item
128 | }
129 |
130 | if
131 | let submenu = item.submenu,
132 | let menuItem = menuItemWithMatchingShortcut(in: submenu)
133 | {
134 | return menuItem
135 | }
136 | }
137 |
138 | return nil
139 | }
140 |
141 | /**
142 | Returns a menu item in the app's main menu that has a matching key equivalent and modifier.
143 | */
144 | var takenByMainMenu: NSMenuItem? {
145 | guard let mainMenu = NSApp.mainMenu else {
146 | return nil
147 | }
148 |
149 | return menuItemWithMatchingShortcut(in: mainMenu)
150 | }
151 | }
152 |
153 | private var keyToCharacterMapping: [KeyboardShortcuts.Key: String] = [
154 | .return: "↩",
155 | .delete: "⌫",
156 | .deleteForward: "⌦",
157 | .end: "↘",
158 | .escape: "⎋",
159 | .help: "?⃝",
160 | .home: "↖",
161 | .space: "⎵",
162 | .tab: "⇥",
163 | .pageUp: "⇞",
164 | .pageDown: "⇟",
165 | .upArrow: "↑",
166 | .rightArrow: "→",
167 | .downArrow: "↓",
168 | .leftArrow: "←",
169 | .f1: "F1",
170 | .f2: "F2",
171 | .f3: "F3",
172 | .f4: "F4",
173 | .f5: "F5",
174 | .f6: "F6",
175 | .f7: "F7",
176 | .f8: "F8",
177 | .f9: "F9",
178 | .f10: "F10",
179 | .f11: "F11",
180 | .f12: "F12",
181 | .f13: "F13",
182 | .f14: "F14",
183 | .f15: "F15",
184 | .f16: "F16",
185 | .f17: "F17",
186 | .f18: "F18",
187 | .f19: "F19",
188 | .f20: "F20"
189 | ]
190 |
191 | private func stringFromKeyCode(_ keyCode: Int) -> String {
192 | String(format: "%C", keyCode)
193 | }
194 |
195 | private var keyToKeyEquivalentString: [KeyboardShortcuts.Key: String] = [
196 | .space: stringFromKeyCode(0x20),
197 | .f1: stringFromKeyCode(NSF1FunctionKey),
198 | .f2: stringFromKeyCode(NSF2FunctionKey),
199 | .f3: stringFromKeyCode(NSF3FunctionKey),
200 | .f4: stringFromKeyCode(NSF4FunctionKey),
201 | .f5: stringFromKeyCode(NSF5FunctionKey),
202 | .f6: stringFromKeyCode(NSF6FunctionKey),
203 | .f7: stringFromKeyCode(NSF7FunctionKey),
204 | .f8: stringFromKeyCode(NSF8FunctionKey),
205 | .f9: stringFromKeyCode(NSF9FunctionKey),
206 | .f10: stringFromKeyCode(NSF10FunctionKey),
207 | .f11: stringFromKeyCode(NSF11FunctionKey),
208 | .f12: stringFromKeyCode(NSF12FunctionKey),
209 | .f13: stringFromKeyCode(NSF13FunctionKey),
210 | .f14: stringFromKeyCode(NSF14FunctionKey),
211 | .f15: stringFromKeyCode(NSF15FunctionKey),
212 | .f16: stringFromKeyCode(NSF16FunctionKey),
213 | .f17: stringFromKeyCode(NSF17FunctionKey),
214 | .f18: stringFromKeyCode(NSF18FunctionKey),
215 | .f19: stringFromKeyCode(NSF19FunctionKey),
216 | .f20: stringFromKeyCode(NSF20FunctionKey)
217 | ]
218 |
219 | extension KeyboardShortcuts.Shortcut {
220 | fileprivate func keyToCharacter() -> String? {
221 | // `TISCopyCurrentASCIICapableKeyboardLayoutInputSource` works on a background thread, but crashes when used in a `NSBackgroundActivityScheduler` task, so we guard against that. It only crashes when running from Xcode, not in release builds, but it's probably safest to not call it from a `NSBackgroundActivityScheduler` no matter what.
222 | assert(!DispatchQueue.isCurrentQueueNSBackgroundActivitySchedulerQueue, "This method cannot be used in a `NSBackgroundActivityScheduler` task")
223 |
224 | // Some characters cannot be automatically translated.
225 | if
226 | let key,
227 | let character = keyToCharacterMapping[key]
228 | {
229 | return character
230 | }
231 |
232 | guard
233 | let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue(),
234 | let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData)
235 | else {
236 | return nil
237 | }
238 |
239 | let layoutData = unsafeBitCast(layoutDataPointer, to: CFData.self)
240 | let keyLayout = unsafeBitCast(CFDataGetBytePtr(layoutData), to: UnsafePointer.self)
241 | var deadKeyState: UInt32 = 0
242 | let maxLength = 4
243 | var length = 0
244 | var characters = [UniChar](repeating: 0, count: maxLength)
245 |
246 | let error = CoreServices.UCKeyTranslate(
247 | keyLayout,
248 | UInt16(carbonKeyCode),
249 | UInt16(CoreServices.kUCKeyActionDisplay),
250 | 0, // No modifiers
251 | UInt32(LMGetKbdType()),
252 | OptionBits(CoreServices.kUCKeyTranslateNoDeadKeysBit),
253 | &deadKeyState,
254 | maxLength,
255 | &length,
256 | &characters
257 | )
258 |
259 | guard error == noErr else {
260 | return nil
261 | }
262 |
263 | return String(utf16CodeUnits: characters, count: length)
264 | }
265 |
266 | // This can be exposed if anyone needs it, but I prefer to keep the API surface small for now.
267 | /**
268 | This can be used to show the keyboard shortcut in a `NSMenuItem` by assigning it to `NSMenuItem#keyEquivalent`.
269 |
270 | - Note: Don't forget to also pass `.modifiers` to `NSMenuItem#keyEquivalentModifierMask`.
271 | */
272 | var keyEquivalent: String {
273 | let keyString = keyToCharacter() ?? ""
274 |
275 | guard keyString.count <= 1 else {
276 | guard
277 | let key,
278 | let string = keyToKeyEquivalentString[key]
279 | else {
280 | return ""
281 | }
282 |
283 | return string
284 | }
285 |
286 | return keyString
287 | }
288 | }
289 |
290 | extension KeyboardShortcuts.Shortcut: CustomStringConvertible {
291 | /**
292 | The string representation of the keyboard shortcut.
293 |
294 | ```swift
295 | print(KeyboardShortcuts.Shortcut(.a, modifiers: [.command]))
296 | //=> "⌘A"
297 | ```
298 | */
299 | public var description: String {
300 | modifiers.description + (keyToCharacter()?.uppercased() ?? "�")
301 | }
302 | }
303 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Utilities.swift:
--------------------------------------------------------------------------------
1 | import Carbon.HIToolbox
2 | import SwiftUI
3 |
4 |
5 | extension String {
6 | /**
7 | Makes the string localizable.
8 | */
9 | var localized: String {
10 | NSLocalizedString(self, bundle: .module, comment: self)
11 | }
12 | }
13 |
14 |
15 | extension Data {
16 | var toString: String? { String(data: self, encoding: .utf8) }
17 | }
18 |
19 |
20 | extension NSEvent {
21 | var isKeyEvent: Bool { type == .keyDown || type == .keyUp }
22 | }
23 |
24 |
25 | extension NSTextField {
26 | func hideCaret() {
27 | (currentEditor() as? NSTextView)?.insertionPointColor = .clear
28 | }
29 | }
30 |
31 |
32 | extension NSView {
33 | func focus() {
34 | window?.makeFirstResponder(self)
35 | }
36 |
37 | func blur() {
38 | window?.makeFirstResponder(nil)
39 | }
40 | }
41 |
42 |
43 | /**
44 | Listen to local events.
45 |
46 | - Important: Don't foret to call `.start()`.
47 |
48 | ```
49 | eventMonitor = LocalEventMonitor(events: [.leftMouseDown, .rightMouseDown]) { event in
50 | // Do something
51 |
52 | return event
53 | }.start()
54 | ```
55 | */
56 | final class LocalEventMonitor {
57 | private let events: NSEvent.EventTypeMask
58 | private let callback: (NSEvent) -> NSEvent?
59 | private weak var monitor: AnyObject?
60 |
61 | init(events: NSEvent.EventTypeMask, callback: @escaping (NSEvent) -> NSEvent?) {
62 | self.events = events
63 | self.callback = callback
64 | }
65 |
66 | deinit {
67 | stop()
68 | }
69 |
70 | @discardableResult
71 | func start() -> Self {
72 | monitor = NSEvent.addLocalMonitorForEvents(matching: events, handler: callback) as AnyObject
73 | return self
74 | }
75 |
76 | func stop() {
77 | guard let monitor else {
78 | return
79 | }
80 |
81 | NSEvent.removeMonitor(monitor)
82 | }
83 | }
84 |
85 |
86 | extension NSEvent {
87 | static var modifiers: ModifierFlags {
88 | modifierFlags
89 | .intersection(.deviceIndependentFlagsMask)
90 | // We remove `capsLock` as it shouldn't affect the modifiers.
91 | // We remove `numericPad`/`function` as arrow keys trigger it, use `event.specialKeys` instead.
92 | .subtracting([.capsLock, .numericPad, .function])
93 | }
94 |
95 | /**
96 | Real modifiers.
97 |
98 | - Note: Prefer this over `.modifierFlags`.
99 |
100 | ```
101 | // Check if Command is one of possible more modifiers keys
102 | event.modifiers.contains(.command)
103 |
104 | // Check if Command is the only modifier key
105 | event.modifiers == .command
106 |
107 | // Check if Command and Shift are the only modifiers
108 | event.modifiers == [.command, .shift]
109 | ```
110 | */
111 | var modifiers: ModifierFlags {
112 | modifierFlags
113 | .intersection(.deviceIndependentFlagsMask)
114 | // We remove `capsLock` as it shouldn't affect the modifiers.
115 | // We remove `numericPad`/`function` as arrow keys trigger it, use `event.specialKeys` instead.
116 | .subtracting([.capsLock, .numericPad, .function])
117 | }
118 | }
119 |
120 |
121 | extension NSSearchField {
122 | /**
123 | Clear the search field.
124 | */
125 | func clear() {
126 | (cell as? NSSearchFieldCell)?.cancelButtonCell?.performClick(self)
127 | }
128 | }
129 |
130 |
131 | extension NSAlert {
132 | /**
133 | Show an alert as a window-modal sheet, or as an app-modal (window-independent) alert if the window is `nil` or not given.
134 | */
135 | @discardableResult
136 | static func showModal(
137 | for window: NSWindow? = nil,
138 | title: String,
139 | message: String? = nil,
140 | style: Style = .warning,
141 | icon: NSImage? = nil
142 | ) -> NSApplication.ModalResponse {
143 | NSAlert(
144 | title: title,
145 | message: message,
146 | style: style,
147 | icon: icon
148 | ).runModal(for: window)
149 | }
150 |
151 | convenience init(
152 | title: String,
153 | message: String? = nil,
154 | style: Style = .warning,
155 | icon: NSImage? = nil
156 | ) {
157 | self.init()
158 | self.messageText = title
159 | self.alertStyle = style
160 | self.icon = icon
161 |
162 | if let message {
163 | self.informativeText = message
164 | }
165 | }
166 |
167 | /**
168 | Runs the alert as a window-modal sheet, or as an app-modal (window-independent) alert if the window is `nil` or not given.
169 | */
170 | @discardableResult
171 | func runModal(for window: NSWindow? = nil) -> NSApplication.ModalResponse {
172 | guard let window else {
173 | return runModal()
174 | }
175 |
176 | beginSheetModal(for: window) { returnCode in
177 | NSApp.stopModal(withCode: returnCode)
178 | }
179 |
180 | return NSApp.runModal(for: window)
181 | }
182 | }
183 |
184 |
185 | enum UnicodeSymbols {
186 | /**
187 | Represents the Function (Fn) key on the keybord.
188 | */
189 | static let functionKey = "🌐\u{FE0E}"
190 | }
191 |
192 |
193 | extension NSEvent.ModifierFlags {
194 | var carbon: Int {
195 | var modifierFlags = 0
196 |
197 | if contains(.control) {
198 | modifierFlags |= controlKey
199 | }
200 |
201 | if contains(.option) {
202 | modifierFlags |= optionKey
203 | }
204 |
205 | if contains(.shift) {
206 | modifierFlags |= shiftKey
207 | }
208 |
209 | if contains(.command) {
210 | modifierFlags |= cmdKey
211 | }
212 |
213 | return modifierFlags
214 | }
215 |
216 | init(carbon: Int) {
217 | self.init()
218 |
219 | if carbon & controlKey == controlKey {
220 | insert(.control)
221 | }
222 |
223 | if carbon & optionKey == optionKey {
224 | insert(.option)
225 | }
226 |
227 | if carbon & shiftKey == shiftKey {
228 | insert(.shift)
229 | }
230 |
231 | if carbon & cmdKey == cmdKey {
232 | insert(.command)
233 | }
234 | }
235 | }
236 |
237 | /// :nodoc:
238 | extension NSEvent.ModifierFlags: CustomStringConvertible {
239 | /**
240 | The string representation of the modifier flags.
241 |
242 | ```
243 | print(NSEvent.ModifierFlags([.command, .shift]))
244 | //=> "⇧⌘"
245 | ```
246 | */
247 | public var description: String {
248 | var description = ""
249 |
250 | if contains(.control) {
251 | description += "⌃"
252 | }
253 |
254 | if contains(.option) {
255 | description += "⌥"
256 | }
257 |
258 | if contains(.shift) {
259 | description += "⇧"
260 | }
261 |
262 | if contains(.command) {
263 | description += "⌘"
264 | }
265 |
266 | if contains(.function) {
267 | description += UnicodeSymbols.functionKey
268 | }
269 |
270 | return description
271 | }
272 | }
273 |
274 |
275 | extension NSEvent.SpecialKey {
276 | static let functionKeys: Set = [
277 | .f1,
278 | .f2,
279 | .f3,
280 | .f4,
281 | .f5,
282 | .f6,
283 | .f7,
284 | .f8,
285 | .f9,
286 | .f10,
287 | .f11,
288 | .f12,
289 | .f13,
290 | .f14,
291 | .f15,
292 | .f16,
293 | .f17,
294 | .f18,
295 | .f19,
296 | .f20,
297 | .f21,
298 | .f22,
299 | .f23,
300 | .f24,
301 | .f25,
302 | .f26,
303 | .f27,
304 | .f28,
305 | .f29,
306 | .f30,
307 | .f31,
308 | .f32,
309 | .f33,
310 | .f34,
311 | .f35
312 | ]
313 |
314 | var isFunctionKey: Bool { Self.functionKeys.contains(self) }
315 | }
316 |
317 |
318 | enum AssociationPolicy {
319 | case assign
320 | case retainNonatomic
321 | case copyNonatomic
322 | case retain
323 | case copy
324 |
325 | var rawValue: objc_AssociationPolicy {
326 | switch self {
327 | case .assign:
328 | return .OBJC_ASSOCIATION_ASSIGN
329 | case .retainNonatomic:
330 | return .OBJC_ASSOCIATION_RETAIN_NONATOMIC
331 | case .copyNonatomic:
332 | return .OBJC_ASSOCIATION_COPY_NONATOMIC
333 | case .retain:
334 | return .OBJC_ASSOCIATION_RETAIN
335 | case .copy:
336 | return .OBJC_ASSOCIATION_COPY
337 | }
338 | }
339 | }
340 |
341 | final class ObjectAssociation {
342 | private let policy: AssociationPolicy
343 |
344 | init(policy: AssociationPolicy = .retainNonatomic) {
345 | self.policy = policy
346 | }
347 |
348 | subscript(index: AnyObject) -> T? {
349 | get {
350 | // Force-cast is fine here as we want it to fail loudly if we don't use the correct type.
351 | // swiftlint:disable:next force_cast
352 | objc_getAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque()) as! T?
353 | }
354 | set {
355 | objc_setAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque(), newValue, policy.rawValue)
356 | }
357 | }
358 | }
359 |
360 |
361 | extension DispatchQueue {
362 | /**
363 | Label of the current dispatch queue.
364 |
365 | - Important: Only meant for debugging purposes.
366 |
367 | ```
368 | DispatchQueue.currentQueueLabel
369 | //=> "com.apple.main-thread"
370 | ```
371 | */
372 | static var currentQueueLabel: String { String(cString: __dispatch_queue_get_label(nil)) }
373 |
374 | /**
375 | Whether the current queue is a `NSBackgroundActivityScheduler` task.
376 | */
377 | static var isCurrentQueueNSBackgroundActivitySchedulerQueue: Bool { currentQueueLabel.hasPrefix("com.apple.xpc.activity.") }
378 | }
379 |
380 |
381 | @available(macOS 10.15, *)
382 | extension HorizontalAlignment {
383 | private enum ControlAlignment: AlignmentID {
384 | static func defaultValue(in context: ViewDimensions) -> CGFloat { // swiftlint:disable:this no_cgfloat
385 | context[HorizontalAlignment.center]
386 | }
387 | }
388 |
389 | fileprivate static let controlAlignment = Self(ControlAlignment.self)
390 | }
391 |
392 | @available(macOS 10.15, *)
393 | extension View {
394 | func formLabel(@ViewBuilder _ label: () -> some View) -> some View {
395 | HStack(alignment: .firstTextBaseline) {
396 | label()
397 | labelsHidden()
398 | .alignmentGuide(.controlAlignment) { $0[.leading] }
399 | }
400 | .alignmentGuide(.leading) { $0[.controlAlignment] }
401 | }
402 | }
403 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/ViewModifiers.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @available(macOS 12, *)
4 | extension View {
5 | /**
6 | Register a listener for keyboard shortcut events with the given name.
7 |
8 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
9 |
10 | The listener will stop automatically when the view disappears.
11 |
12 | - Note: This method is not affected by `.removeAllHandlers()`.
13 | */
14 | @MainActor
15 | public func onKeyboardShortcut(_ shortcut: KeyboardShortcuts.Name, perform: @escaping (KeyboardShortcuts.EventType) -> Void) -> some View {
16 | task {
17 | for await eventType in KeyboardShortcuts.events(for: shortcut) {
18 | perform(eventType)
19 | }
20 | }
21 | }
22 |
23 | /**
24 | Register a listener for keyboard shortcut events with the given name and type.
25 |
26 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
27 |
28 | The listener will stop automatically when the view disappears.
29 |
30 | - Note: This method is not affected by `.removeAllHandlers()`.
31 | */
32 | @MainActor
33 | public func onKeyboardShortcut(_ shortcut: KeyboardShortcuts.Name, type: KeyboardShortcuts.EventType, perform: @escaping () -> Void) -> some View {
34 | task {
35 | for await _ in KeyboardShortcuts.events(type, for: shortcut) {
36 | perform()
37 | }
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Tests/KeyboardShortcutsTests/KeyboardShortcutsTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import KeyboardShortcuts
3 |
4 | final class KeyboardShortcutsTests: XCTestCase {
5 | // TODO: Add more tests.
6 |
7 | // swiftlint:disable:next overridden_super_call
8 | override func setUpWithError() throws {
9 | UserDefaults.standard.removeAll()
10 | }
11 |
12 | func testSetShortcutAndReset() throws {
13 | let defaultShortcut = KeyboardShortcuts.Shortcut(.c)
14 | let shortcut1 = KeyboardShortcuts.Shortcut(.a)
15 | let shortcut2 = KeyboardShortcuts.Shortcut(.b)
16 |
17 | let shortcutName1 = KeyboardShortcuts.Name("testSetShortcutAndReset1")
18 | let shortcutName2 = KeyboardShortcuts.Name("testSetShortcutAndReset2", default: defaultShortcut)
19 |
20 | KeyboardShortcuts.setShortcut(shortcut1, for: shortcutName1)
21 | KeyboardShortcuts.setShortcut(shortcut2, for: shortcutName2)
22 |
23 | XCTAssertEqual(KeyboardShortcuts.getShortcut(for: shortcutName1), shortcut1)
24 | XCTAssertEqual(KeyboardShortcuts.getShortcut(for: shortcutName2), shortcut2)
25 |
26 | KeyboardShortcuts.reset(shortcutName1, shortcutName2)
27 |
28 | XCTAssertNil(KeyboardShortcuts.getShortcut(for: shortcutName1))
29 | XCTAssertEqual(KeyboardShortcuts.getShortcut(for: shortcutName2), defaultShortcut)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Tests/KeyboardShortcutsTests/Utilities.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension UserDefaults {
4 | /**
5 | Remove all entries.
6 |
7 | - Note: This only removes user-defined entries. System-defined entries will remain.
8 | */
9 | public func removeAll() {
10 | for key in dictionaryRepresentation().keys {
11 | removeObject(forKey: key)
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/license:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/logo-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rxhanson/KeyboardShortcuts/d7b349f6822e24228141e560aa48a32dca23b22c/logo-dark.png
--------------------------------------------------------------------------------
/logo-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rxhanson/KeyboardShortcuts/d7b349f6822e24228141e560aa48a32dca23b22c/logo-light.png
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |

4 |
5 |
6 |
7 | This package lets you add support for user-customizable global keyboard shortcuts to your macOS app in minutes. It's fully sandbox and Mac App Store compatible. And it's used in production by [Dato](https://sindresorhus.com/dato), [Jiffy](https://sindresorhus.com/jiffy), [Plash](https://github.com/sindresorhus/Plash), and [Lungo](https://sindresorhus.com/lungo).
8 |
9 | I'm happy to accept more configurability and features. PR welcome! What you see here is just what I needed for my own apps.
10 |
11 |
12 |
13 | ## Requirements
14 |
15 | macOS 10.13+
16 |
17 | ## Install
18 |
19 | Add `https://github.com/sindresorhus/KeyboardShortcuts` in the [“Swift Package Manager” tab in Xcode](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app).
20 |
21 | ## Usage
22 |
23 | First, register a name for the keyboard shortcut.
24 |
25 | `Constants.swift`
26 |
27 | ```swift
28 | import KeyboardShortcuts
29 |
30 | extension KeyboardShortcuts.Name {
31 | static let toggleUnicornMode = Self("toggleUnicornMode")
32 | }
33 | ```
34 |
35 | You can then refer to this strongly-typed name in other places.
36 |
37 | You will want to make a view where the user can choose a keyboard shortcut.
38 |
39 | `SettingsScreen.swift`
40 |
41 | ```swift
42 | import SwiftUI
43 | import KeyboardShortcuts
44 |
45 | struct SettingsScreen: View {
46 | var body: some View {
47 | Form {
48 | KeyboardShortcuts.Recorder("Toggle Unicorn Mode:", name: .toggleUnicornMode)
49 | }
50 | }
51 | }
52 | ```
53 |
54 | *There's also [support for Cocoa](#cocoa) instead of SwiftUI.*
55 |
56 | `KeyboardShortcuts.Recorder` takes care of storing the keyboard shortcut in `UserDefaults` and also warning the user if the chosen keyboard shortcut is already used by the system or the app's main menu.
57 |
58 | Add a listener for when the user presses their chosen keyboard shortcut.
59 |
60 | `App.swift`
61 |
62 | ```swift
63 | import SwiftUI
64 | import KeyboardShortcuts
65 |
66 | @main
67 | struct YourApp: App {
68 | @StateObject private var appState = AppState()
69 |
70 | var body: some Scene {
71 | WindowGroup {
72 | // …
73 | }
74 | Settings {
75 | SettingsScreen()
76 | }
77 | }
78 | }
79 |
80 | @MainActor
81 | final class AppState: ObservableObject {
82 | init() {
83 | KeyboardShortcuts.onKeyUp(for: .toggleUnicornMode) { [self] in
84 | isUnicornMode.toggle()
85 | }
86 | }
87 | }
88 | ```
89 |
90 | *You can also listen to key down with `.onKeyDown()`*
91 |
92 | **That's all! ✨**
93 |
94 | You can find a complete example in the “Example” directory.
95 |
96 | You can also find a [real-world example](https://github.com/sindresorhus/Plash/blob/b348a62645a873abba8dc11ff0fb8fe423419411/Plash/PreferencesView.swift#L121-L130) in my Plash app.
97 |
98 | #### Cocoa
99 |
100 | Using [`KeyboardShortcuts.RecorderCocoa`](Sources/KeyboardShortcuts/RecorderCocoa.swift) instead of `KeyboardShortcuts.Recorder`:
101 |
102 | ```swift
103 | import Cocoa
104 | import KeyboardShortcuts
105 |
106 | final class SettingsViewController: NSViewController {
107 | override func loadView() {
108 | view = NSView()
109 |
110 | let recorder = KeyboardShortcuts.RecorderCocoa(for: .toggleUnicornMode)
111 | view.addSubview(recorder)
112 | }
113 | }
114 | ```
115 |
116 | ## Localization
117 |
118 | This package supports [localizations](/Sources/KeyboardShortcuts/Localization). PR welcome for more!
119 |
120 | 1. Fork the repo.
121 | 2. Create a directory that has a name that uses an [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language code and optional designators, followed by the `.lproj` suffix. [More here.](https://developer.apple.com/documentation/swift_packages/localizing_package_resources)
122 | 3. Create a file named `Localizable.strings` under the new language directory and then copy the contents of `KeyboardShortcuts/Localization/en.lproj/Localizable.strings` to the new file that you just created.
123 | 4. Localize and make sure to review your localization multiple times. Check for typos.
124 | 5. Try to find someone that speaks your language to review the translation.
125 | 6. Submit a PR.
126 |
127 | ## API
128 |
129 | [See the API docs.](https://swiftpackageindex.com/sindresorhus/KeyboardShortcuts/documentation/keyboardshortcuts/keyboardshortcuts)
130 |
131 | ## Tips
132 |
133 | #### Show a recorded keyboard shortcut in an `NSMenuItem`
134 |
135 |
136 |
137 | See [`NSMenuItem#setShortcut`](https://github.com/sindresorhus/KeyboardShortcuts/blob/0dcedd56994d871f243f3d9c76590bfd9f8aba69/Sources/KeyboardShortcuts/NSMenuItem%2B%2B.swift#L14-L41).
138 |
139 | #### Dynamic keyboard shortcuts
140 |
141 | Your app might need to support keyboard shortcuts for user-defined actions. Normally, you would statically register the keyboard shortcuts upfront in `extension KeyboardShortcuts.Name {}`. However, this is not a requirement. It's only for convenience so that you can use dot-syntax when calling various APIs (for example, `.onKeyDown(.unicornMode) {}`). You can create `KeyboardShortcut.Name`'s dynamically and store them yourself. You can see this in action in the example project.
142 |
143 | #### Default keyboard shortcuts
144 |
145 | Setting a default keyboard shortcut can be useful if you're migrating from a different package or just making something for yourself. However, please do not set this for a publicly distributed app. Users find it annoying when random apps steal their existing keyboard shortcuts. It’s generally better to show a welcome screen on the first app launch that lets the user set the shortcut.
146 |
147 | ```swift
148 | import KeyboardShortcuts
149 |
150 | extension KeyboardShortcuts.Name {
151 | static let toggleUnicornMode = Self("toggleUnicornMode", default: .init(.k, modifiers: [.command, .option]))
152 | }
153 | ```
154 |
155 | #### Get all keyboard shortcuts
156 |
157 | To get all the keyboard shortcut `Name`'s, conform `KeyboardShortcuts.Name` to `CaseIterable`.
158 |
159 | ```swift
160 | import KeyboardShortcuts
161 |
162 | extension KeyboardShortcuts.Name {
163 | static let foo = Self("foo")
164 | static let bar = Self("bar")
165 | }
166 |
167 | extension KeyboardShortcuts.Name: CaseIterable {
168 | public static let allCases: [Self] = [
169 | .foo,
170 | .bar
171 | ]
172 | }
173 |
174 | // …
175 |
176 | print(KeyboardShortcuts.Name.allCases)
177 | ```
178 |
179 | And to get all the `Name`'s with a set keyboard shortcut:
180 |
181 | ```swift
182 | print(KeyboardShortcuts.Name.allCases.filter { $0.shortcut != nil })
183 | ```
184 |
185 | ## FAQ
186 |
187 | #### How is it different from [`MASShortcut`](https://github.com/shpakovski/MASShortcut)?
188 |
189 | This package:
190 | - Written in Swift with a swifty API.
191 | - More native-looking UI component.
192 | - SwiftUI component included.
193 | - Support for listening to key down, not just key up.
194 | - Swift Package Manager support.
195 | - Connect a shortcut to an `NSMenuItem`.
196 |
197 | `MASShortcut`:
198 | - More mature.
199 | - More localizations.
200 |
201 | #### How is it different from [`HotKey`](https://github.com/soffes/HotKey)?
202 |
203 | `HotKey` is good for adding hard-coded keyboard shortcuts, but it doesn't provide any UI component for the user to choose their own keyboard shortcuts.
204 |
205 | #### Why is this package importing `Carbon`? Isn't that deprecated?
206 |
207 | Most of the Carbon APIs were deprecated years ago, but there are some left that Apple never shipped modern replacements for. This includes registering global keyboard shortcuts. However, you should not need to worry about this. Apple will for sure ship new APIs before deprecating the Carbon APIs used here.
208 |
209 | #### Does this package cause any permission dialogs?
210 |
211 | No.
212 |
213 | #### How can I add an app-specific keyboard shortcut that is only active when the app is?
214 |
215 | That is outside the scope of this package. You can either use [`NSEvent.addLocalMonitorForEvents`](https://developer.apple.com/documentation/appkit/nsevent/1534971-addlocalmonitorforevents), [`NSMenuItem` with keyboard shortcut](https://developer.apple.com/documentation/appkit/nsmenuitem/2880316-allowskeyequivalentwhenhidden) (it can even be hidden), or SwiftUI's [`View#keyboardShortcut()` modifier](https://developer.apple.com/documentation/swiftui/form/keyboardshortcut(_:)).
216 |
217 | #### Does it support media keys?
218 |
219 | No, since it would not work for sandboxed apps. If your app is not sandboxed, you can use [`MediaKeyTap`](https://github.com/nhurden/MediaKeyTap).
220 |
221 | #### Can you support CocoaPods or Carthage?
222 |
223 | No. However, there is nothing stopping you from using Swift Package Manager for just this package even if you normally use CocoaPods or Carthage.
224 |
225 | ## Related
226 |
227 | - [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults
228 | - [Regex](https://github.com/sindresorhus/Regex) - Swifty regular expressions
229 | - [Preferences](https://github.com/sindresorhus/Preferences) - Add a settings window to your macOS app in minutes
230 | - [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Add "Launch at Login" functionality to your macOS app
231 | - [More…](https://github.com/search?q=user%3Asindresorhus+language%3Aswift)
232 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rxhanson/KeyboardShortcuts/d7b349f6822e24228141e560aa48a32dca23b22c/screenshot.png
--------------------------------------------------------------------------------