├── .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
│ ├── 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
│ ├── ko.lproj
│ │ └── Localizable.strings
│ ├── nl.lproj
│ │ └── Localizable.strings
│ ├── pt-BR.lproj
│ │ └── Localizable.strings
│ ├── ru.lproj
│ │ └── Localizable.strings
│ ├── sk.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 | - accessibility_trait_for_button
3 | - array_init
4 | - blanket_disable_command
5 | - block_based_kvo
6 | - class_delegate_protocol
7 | - closing_brace
8 | - closure_end_indentation
9 | - closure_parameter_position
10 | - closure_spacing
11 | - collection_alignment
12 | - colon
13 | - comma
14 | - comma_inheritance
15 | - compiler_protocol_init
16 | - computed_accessors_order
17 | - conditional_returns_on_newline
18 | - contains_over_filter_count
19 | - contains_over_filter_is_empty
20 | - contains_over_first_not_nil
21 | - contains_over_range_nil_comparison
22 | - control_statement
23 | - custom_rules
24 | - deployment_target
25 | - direct_return
26 | - discarded_notification_center_observer
27 | - discouraged_assert
28 | - discouraged_direct_init
29 | - discouraged_none_name
30 | - discouraged_object_literal
31 | - discouraged_optional_boolean
32 | - discouraged_optional_collection
33 | - duplicate_conditions
34 | - duplicate_enum_cases
35 | - duplicate_imports
36 | - duplicated_key_in_dictionary_literal
37 | - dynamic_inline
38 | - empty_collection_literal
39 | - empty_count
40 | - empty_enum_arguments
41 | - empty_parameters
42 | - empty_parentheses_with_trailing_closure
43 | - empty_string
44 | - empty_xctest_method
45 | - enum_case_associated_values_count
46 | - explicit_init
47 | - fallthrough
48 | - fatal_error_message
49 | - final_test_case
50 | - first_where
51 | - flatmap_over_map_reduce
52 | - for_where
53 | - generic_type_name
54 | - ibinspectable_in_extension
55 | - identical_operands
56 | - identifier_name
57 | - implicit_getter
58 | - implicit_return
59 | - inclusive_language
60 | - invalid_swiftlint_command
61 | - is_disjoint
62 | - joined_default_parameter
63 | - last_where
64 | - leading_whitespace
65 | - legacy_cggeometry_functions
66 | - legacy_constant
67 | - legacy_constructor
68 | - legacy_hashing
69 | - legacy_multiple
70 | - legacy_nsgeometry_functions
71 | - legacy_random
72 | - literal_expression_end_indentation
73 | - lower_acl_than_parent
74 | - mark
75 | - modifier_order
76 | - multiline_arguments
77 | - multiline_arguments_brackets
78 | - multiline_function_chains
79 | - multiline_literal_brackets
80 | - multiline_parameters
81 | - multiline_parameters_brackets
82 | - nimble_operator
83 | - no_extension_access_modifier
84 | - no_fallthrough_only
85 | - no_space_in_method_call
86 | - non_optional_string_data_conversion
87 | - non_overridable_class_declaration
88 | - notification_center_detachment
89 | - ns_number_init_as_function_reference
90 | - nsobject_prefer_isequal
91 | - number_separator
92 | - operator_usage_whitespace
93 | - operator_whitespace
94 | - overridden_super_call
95 | - prefer_self_in_static_references
96 | - prefer_self_type_over_type_of_self
97 | - prefer_zero_over_explicit_init
98 | - private_action
99 | - private_outlet
100 | - private_subject
101 | - private_swiftui_state
102 | - private_unit_test
103 | - prohibited_super_call
104 | - protocol_property_accessors_order
105 | - reduce_boolean
106 | - reduce_into
107 | - redundant_discardable_let
108 | - redundant_nil_coalescing
109 | - redundant_objc_attribute
110 | - redundant_optional_initialization
111 | - redundant_set_access_control
112 | - redundant_string_enum_value
113 | - redundant_type_annotation
114 | - redundant_void_return
115 | - required_enum_case
116 | - return_arrow_whitespace
117 | - return_value_from_void_function
118 | - self_binding
119 | - self_in_property_initialization
120 | - shorthand_operator
121 | - shorthand_optional_binding
122 | - sorted_first_last
123 | - statement_position
124 | - static_operator
125 | - static_over_final_class
126 | - strong_iboutlet
127 | - superfluous_disable_command
128 | - superfluous_else
129 | - switch_case_alignment
130 | - switch_case_on_newline
131 | - syntactic_sugar
132 | - test_case_accessibility
133 | - toggle_bool
134 | - trailing_closure
135 | - trailing_comma
136 | - trailing_newline
137 | - trailing_semicolon
138 | - trailing_whitespace
139 | - unavailable_condition
140 | - unavailable_function
141 | - unneeded_break_in_switch
142 | - unneeded_override
143 | - unneeded_parentheses_in_closure_argument
144 | - unowned_variable_capture
145 | - untyped_error_in_catch
146 | - unused_closure_parameter
147 | - unused_control_flow_label
148 | - unused_enumerated
149 | - unused_optional_binding
150 | - unused_setter_value
151 | - valid_ibinspectable
152 | - vertical_parameter_alignment
153 | - vertical_parameter_alignment_on_call
154 | - vertical_whitespace_closing_braces
155 | - vertical_whitespace_opening_braces
156 | - void_function_in_ternary
157 | - void_return
158 | - xct_specific_matcher
159 | - xctfail_message
160 | - yoda_condition
161 | analyzer_rules:
162 | - capture_variable
163 | - typesafe_array_init
164 | - unneeded_synthesized_initializer
165 | - unused_declaration
166 | - unused_import
167 | for_where:
168 | allow_for_as_filter: true
169 | number_separator:
170 | minimum_length: 5
171 | identifier_name:
172 | max_length:
173 | warning: 100
174 | error: 100
175 | min_length:
176 | warning: 2
177 | error: 2
178 | allowed_symbols:
179 | - '_'
180 | excluded:
181 | - 'x'
182 | - 'y'
183 | - 'z'
184 | - 'a'
185 | - 'b'
186 | - 'x1'
187 | - 'x2'
188 | - 'y1'
189 | - 'y2'
190 | - 'z2'
191 | redundant_type_annotation:
192 | consider_default_literal_types_redundant: true
193 | unneeded_override:
194 | affect_initializers: true
195 | deployment_target:
196 | macOS_deployment_target: '10.11'
197 | custom_rules:
198 | no_nsrect:
199 | regex: '\bNSRect\b'
200 | match_kinds: typeidentifier
201 | message: 'Use CGRect instead of NSRect'
202 | no_nssize:
203 | regex: '\bNSSize\b'
204 | match_kinds: typeidentifier
205 | message: 'Use CGSize instead of NSSize'
206 | no_nspoint:
207 | regex: '\bNSPoint\b'
208 | match_kinds: typeidentifier
209 | message: 'Use CGPoint instead of NSPoint'
210 | no_cgfloat:
211 | regex: '\bCGFloat\b'
212 | match_kinds: typeidentifier
213 | message: 'Use Double instead of CGFloat'
214 | no_cgfloat2:
215 | regex: '\bCGFloat\('
216 | message: 'Use Double instead of CGFloat'
217 | swiftui_state_private:
218 | regex: '@(ObservedObject|EnvironmentObject)\s+var'
219 | message: 'SwiftUI @ObservedObject and @EnvironmentObject properties should be private'
220 | swiftui_environment_private:
221 | regex: '@Environment\(\\\.\w+\)\s+var'
222 | message: 'SwiftUI @Environment properties should be private'
223 | final_class:
224 | regex: '^class [a-zA-Z\d]+[^{]+\{'
225 | message: 'Classes should be marked as final whenever possible. If you actually need it to be subclassable, just add `// swiftlint:disable:next final_class`.'
226 | no_alignment_center:
227 | regex: '\b\(alignment: .center\b'
228 | message: 'This alignment is the default.'
229 |
--------------------------------------------------------------------------------
/.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 | E36FB94A2609BA43004272D9 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB9492609BA43004272D9 /* App.swift */; };
11 | E36FB94C2609BA43004272D9 /* MainScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB94B2609BA43004272D9 /* MainScreen.swift */; };
12 | E36FB94E2609BA45004272D9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E36FB94D2609BA45004272D9 /* Assets.xcassets */; };
13 | E36FB9512609BA45004272D9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E36FB9502609BA45004272D9 /* Preview Assets.xcassets */; };
14 | E36FB9632609BB83004272D9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB9622609BB83004272D9 /* AppState.swift */; };
15 | E36FB9662609BF3D004272D9 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB9652609BF3D004272D9 /* Utilities.swift */; };
16 | E3A680512A042A0B00715D81 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = E3A680502A042A0B00715D81 /* KeyboardShortcuts */; };
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 | E36FB9532609BA45004272D9 /* KeyboardShortcutsExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeyboardShortcutsExample.entitlements; sourceTree = ""; };
27 | E36FB9622609BB83004272D9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; };
28 | E36FB9652609BF3D004272D9 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; };
29 | /* End PBXFileReference section */
30 |
31 | /* Begin PBXFrameworksBuildPhase section */
32 | E36FB9432609BA43004272D9 /* Frameworks */ = {
33 | isa = PBXFrameworksBuildPhase;
34 | buildActionMask = 2147483647;
35 | files = (
36 | E3A680512A042A0B00715D81 /* KeyboardShortcuts in Frameworks */,
37 | );
38 | runOnlyForDeploymentPostprocessing = 0;
39 | };
40 | /* End PBXFrameworksBuildPhase section */
41 |
42 | /* Begin PBXGroup section */
43 | E33F1EF926F3B78800ACEB0F /* Packages */ = {
44 | isa = PBXGroup;
45 | children = (
46 | E33F1EFA26F3B78800ACEB0F /* KeyboardShortcuts */,
47 | );
48 | name = Packages;
49 | sourceTree = "";
50 | };
51 | E36FB93D2609BA43004272D9 = {
52 | isa = PBXGroup;
53 | children = (
54 | E36FB9482609BA43004272D9 /* KeyboardShortcutsExample */,
55 | E36FB9472609BA43004272D9 /* Products */,
56 | E33F1EF926F3B78800ACEB0F /* Packages */,
57 | E3A6804F2A042A0B00715D81 /* Frameworks */,
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 | E36FB9532609BA45004272D9 /* KeyboardShortcutsExample.entitlements */,
78 | E36FB94F2609BA45004272D9 /* Preview Content */,
79 | );
80 | path = KeyboardShortcutsExample;
81 | sourceTree = "";
82 | };
83 | E36FB94F2609BA45004272D9 /* Preview Content */ = {
84 | isa = PBXGroup;
85 | children = (
86 | E36FB9502609BA45004272D9 /* Preview Assets.xcassets */,
87 | );
88 | path = "Preview Content";
89 | sourceTree = "";
90 | };
91 | E3A6804F2A042A0B00715D81 /* Frameworks */ = {
92 | isa = PBXGroup;
93 | children = (
94 | );
95 | name = Frameworks;
96 | sourceTree = "";
97 | };
98 | /* End PBXGroup section */
99 |
100 | /* Begin PBXNativeTarget section */
101 | E36FB9452609BA43004272D9 /* KeyboardShortcutsExample */ = {
102 | isa = PBXNativeTarget;
103 | buildConfigurationList = E36FB9562609BA45004272D9 /* Build configuration list for PBXNativeTarget "KeyboardShortcutsExample" */;
104 | buildPhases = (
105 | E36FB9422609BA43004272D9 /* Sources */,
106 | E36FB9432609BA43004272D9 /* Frameworks */,
107 | E36FB9442609BA43004272D9 /* Resources */,
108 | );
109 | buildRules = (
110 | );
111 | dependencies = (
112 | );
113 | name = KeyboardShortcutsExample;
114 | packageProductDependencies = (
115 | E3A680502A042A0B00715D81 /* KeyboardShortcuts */,
116 | );
117 | productName = KeyboardShortcutsExample;
118 | productReference = E36FB9462609BA43004272D9 /* KeyboardShortcutsExample.app */;
119 | productType = "com.apple.product-type.application";
120 | };
121 | /* End PBXNativeTarget section */
122 |
123 | /* Begin PBXProject section */
124 | E36FB93E2609BA43004272D9 /* Project object */ = {
125 | isa = PBXProject;
126 | attributes = {
127 | BuildIndependentTargetsInParallel = YES;
128 | LastSwiftUpdateCheck = 1240;
129 | LastUpgradeCheck = 1530;
130 | TargetAttributes = {
131 | E36FB9452609BA43004272D9 = {
132 | CreatedOnToolsVersion = 12.4;
133 | };
134 | };
135 | };
136 | buildConfigurationList = E36FB9412609BA43004272D9 /* Build configuration list for PBXProject "KeyboardShortcutsExample" */;
137 | compatibilityVersion = "Xcode 14.0";
138 | developmentRegion = en;
139 | hasScannedForEncodings = 0;
140 | knownRegions = (
141 | en,
142 | Base,
143 | );
144 | mainGroup = E36FB93D2609BA43004272D9;
145 | productRefGroup = E36FB9472609BA43004272D9 /* Products */;
146 | projectDirPath = "";
147 | projectRoot = "";
148 | targets = (
149 | E36FB9452609BA43004272D9 /* KeyboardShortcutsExample */,
150 | );
151 | };
152 | /* End PBXProject section */
153 |
154 | /* Begin PBXResourcesBuildPhase section */
155 | E36FB9442609BA43004272D9 /* Resources */ = {
156 | isa = PBXResourcesBuildPhase;
157 | buildActionMask = 2147483647;
158 | files = (
159 | E36FB9512609BA45004272D9 /* Preview Assets.xcassets in Resources */,
160 | E36FB94E2609BA45004272D9 /* Assets.xcassets in Resources */,
161 | );
162 | runOnlyForDeploymentPostprocessing = 0;
163 | };
164 | /* End PBXResourcesBuildPhase section */
165 |
166 | /* Begin PBXSourcesBuildPhase section */
167 | E36FB9422609BA43004272D9 /* Sources */ = {
168 | isa = PBXSourcesBuildPhase;
169 | buildActionMask = 2147483647;
170 | files = (
171 | E36FB9632609BB83004272D9 /* AppState.swift in Sources */,
172 | E36FB94C2609BA43004272D9 /* MainScreen.swift in Sources */,
173 | E36FB9662609BF3D004272D9 /* Utilities.swift in Sources */,
174 | E36FB94A2609BA43004272D9 /* App.swift in Sources */,
175 | );
176 | runOnlyForDeploymentPostprocessing = 0;
177 | };
178 | /* End PBXSourcesBuildPhase section */
179 |
180 | /* Begin XCBuildConfiguration section */
181 | E36FB9542609BA45004272D9 /* Debug */ = {
182 | isa = XCBuildConfiguration;
183 | buildSettings = {
184 | ALWAYS_SEARCH_USER_PATHS = NO;
185 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
186 | CLANG_ANALYZER_NONNULL = YES;
187 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
188 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
189 | CLANG_CXX_LIBRARY = "libc++";
190 | CLANG_ENABLE_MODULES = YES;
191 | CLANG_ENABLE_OBJC_ARC = YES;
192 | CLANG_ENABLE_OBJC_WEAK = YES;
193 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
194 | CLANG_WARN_BOOL_CONVERSION = YES;
195 | CLANG_WARN_COMMA = YES;
196 | CLANG_WARN_CONSTANT_CONVERSION = YES;
197 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
198 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
199 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
200 | CLANG_WARN_EMPTY_BODY = YES;
201 | CLANG_WARN_ENUM_CONVERSION = YES;
202 | CLANG_WARN_INFINITE_RECURSION = YES;
203 | CLANG_WARN_INT_CONVERSION = YES;
204 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
205 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
206 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
207 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
208 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
209 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
210 | CLANG_WARN_STRICT_PROTOTYPES = YES;
211 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
212 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
213 | CLANG_WARN_UNREACHABLE_CODE = YES;
214 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
215 | COPY_PHASE_STRIP = NO;
216 | DEAD_CODE_STRIPPING = YES;
217 | DEBUG_INFORMATION_FORMAT = dwarf;
218 | ENABLE_STRICT_OBJC_MSGSEND = YES;
219 | ENABLE_TESTABILITY = YES;
220 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
221 | GCC_C_LANGUAGE_STANDARD = gnu11;
222 | GCC_DYNAMIC_NO_PIC = NO;
223 | GCC_NO_COMMON_BLOCKS = YES;
224 | GCC_OPTIMIZATION_LEVEL = 0;
225 | GCC_PREPROCESSOR_DEFINITIONS = (
226 | "DEBUG=1",
227 | "$(inherited)",
228 | );
229 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
230 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
231 | GCC_WARN_UNDECLARED_SELECTOR = YES;
232 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
233 | GCC_WARN_UNUSED_FUNCTION = YES;
234 | GCC_WARN_UNUSED_VARIABLE = YES;
235 | MACOSX_DEPLOYMENT_TARGET = 14.4;
236 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
237 | MTL_FAST_MATH = YES;
238 | ONLY_ACTIVE_ARCH = YES;
239 | SDKROOT = macosx;
240 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
241 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
242 | };
243 | name = Debug;
244 | };
245 | E36FB9552609BA45004272D9 /* Release */ = {
246 | isa = XCBuildConfiguration;
247 | buildSettings = {
248 | ALWAYS_SEARCH_USER_PATHS = NO;
249 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
250 | CLANG_ANALYZER_NONNULL = YES;
251 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
252 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
253 | CLANG_CXX_LIBRARY = "libc++";
254 | CLANG_ENABLE_MODULES = YES;
255 | CLANG_ENABLE_OBJC_ARC = YES;
256 | CLANG_ENABLE_OBJC_WEAK = YES;
257 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
258 | CLANG_WARN_BOOL_CONVERSION = YES;
259 | CLANG_WARN_COMMA = YES;
260 | CLANG_WARN_CONSTANT_CONVERSION = YES;
261 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
262 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
263 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
264 | CLANG_WARN_EMPTY_BODY = YES;
265 | CLANG_WARN_ENUM_CONVERSION = YES;
266 | CLANG_WARN_INFINITE_RECURSION = YES;
267 | CLANG_WARN_INT_CONVERSION = YES;
268 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
269 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
270 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
271 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
272 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
273 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
274 | CLANG_WARN_STRICT_PROTOTYPES = YES;
275 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
276 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
277 | CLANG_WARN_UNREACHABLE_CODE = YES;
278 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
279 | COPY_PHASE_STRIP = NO;
280 | DEAD_CODE_STRIPPING = YES;
281 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
282 | ENABLE_NS_ASSERTIONS = NO;
283 | ENABLE_STRICT_OBJC_MSGSEND = YES;
284 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
285 | GCC_C_LANGUAGE_STANDARD = gnu11;
286 | GCC_NO_COMMON_BLOCKS = YES;
287 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
288 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
289 | GCC_WARN_UNDECLARED_SELECTOR = YES;
290 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
291 | GCC_WARN_UNUSED_FUNCTION = YES;
292 | GCC_WARN_UNUSED_VARIABLE = YES;
293 | MACOSX_DEPLOYMENT_TARGET = 14.4;
294 | MTL_ENABLE_DEBUG_INFO = NO;
295 | MTL_FAST_MATH = YES;
296 | SDKROOT = macosx;
297 | SWIFT_COMPILATION_MODE = wholemodule;
298 | SWIFT_OPTIMIZATION_LEVEL = "-O";
299 | };
300 | name = Release;
301 | };
302 | E36FB9572609BA45004272D9 /* Debug */ = {
303 | isa = XCBuildConfiguration;
304 | buildSettings = {
305 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
306 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
307 | CODE_SIGN_ENTITLEMENTS = KeyboardShortcutsExample/KeyboardShortcutsExample.entitlements;
308 | CODE_SIGN_IDENTITY = "-";
309 | CODE_SIGN_STYLE = Automatic;
310 | COMBINE_HIDPI_IMAGES = YES;
311 | CURRENT_PROJECT_VERSION = 1;
312 | DEAD_CODE_STRIPPING = YES;
313 | DEVELOPMENT_ASSET_PATHS = "\"KeyboardShortcutsExample/Preview Content\"";
314 | DEVELOPMENT_TEAM = "";
315 | ENABLE_HARDENED_RUNTIME = YES;
316 | ENABLE_PREVIEWS = YES;
317 | GENERATE_INFOPLIST_FILE = YES;
318 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
319 | LD_RUNPATH_SEARCH_PATHS = (
320 | "$(inherited)",
321 | "@executable_path/../Frameworks",
322 | );
323 | MARKETING_VERSION = 1.0.0;
324 | PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.KeyboardShortcutsExample;
325 | PRODUCT_NAME = "$(TARGET_NAME)";
326 | SWIFT_STRICT_CONCURRENCY = complete;
327 | SWIFT_VERSION = 5.0;
328 | };
329 | name = Debug;
330 | };
331 | E36FB9582609BA45004272D9 /* Release */ = {
332 | isa = XCBuildConfiguration;
333 | buildSettings = {
334 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
335 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
336 | CODE_SIGN_ENTITLEMENTS = KeyboardShortcutsExample/KeyboardShortcutsExample.entitlements;
337 | CODE_SIGN_IDENTITY = "-";
338 | CODE_SIGN_STYLE = Automatic;
339 | COMBINE_HIDPI_IMAGES = YES;
340 | CURRENT_PROJECT_VERSION = 1;
341 | DEAD_CODE_STRIPPING = YES;
342 | DEVELOPMENT_ASSET_PATHS = "\"KeyboardShortcutsExample/Preview Content\"";
343 | DEVELOPMENT_TEAM = "";
344 | ENABLE_HARDENED_RUNTIME = YES;
345 | ENABLE_PREVIEWS = YES;
346 | GENERATE_INFOPLIST_FILE = YES;
347 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
348 | LD_RUNPATH_SEARCH_PATHS = (
349 | "$(inherited)",
350 | "@executable_path/../Frameworks",
351 | );
352 | MARKETING_VERSION = 1.0.0;
353 | PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.KeyboardShortcutsExample;
354 | PRODUCT_NAME = "$(TARGET_NAME)";
355 | SWIFT_STRICT_CONCURRENCY = complete;
356 | SWIFT_VERSION = 5.0;
357 | };
358 | name = Release;
359 | };
360 | /* End XCBuildConfiguration section */
361 |
362 | /* Begin XCConfigurationList section */
363 | E36FB9412609BA43004272D9 /* Build configuration list for PBXProject "KeyboardShortcutsExample" */ = {
364 | isa = XCConfigurationList;
365 | buildConfigurations = (
366 | E36FB9542609BA45004272D9 /* Debug */,
367 | E36FB9552609BA45004272D9 /* Release */,
368 | );
369 | defaultConfigurationIsVisible = 0;
370 | defaultConfigurationName = Release;
371 | };
372 | E36FB9562609BA45004272D9 /* Build configuration list for PBXNativeTarget "KeyboardShortcutsExample" */ = {
373 | isa = XCConfigurationList;
374 | buildConfigurations = (
375 | E36FB9572609BA45004272D9 /* Debug */,
376 | E36FB9582609BA45004272D9 /* Release */,
377 | );
378 | defaultConfigurationIsVisible = 0;
379 | defaultConfigurationName = Release;
380 | };
381 | /* End XCConfigurationList section */
382 |
383 | /* Begin XCSwiftPackageProductDependency section */
384 | E3A680502A042A0B00715D81 /* KeyboardShortcuts */ = {
385 | isa = XCSwiftPackageProductDependency;
386 | productName = KeyboardShortcuts;
387 | };
388 | /* End XCSwiftPackageProductDependency section */
389 | };
390 | rootObject = E36FB93E2609BA43004272D9 /* Project object */;
391 | }
392 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/App.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct AppMain: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | MainScreen()
8 | .task {
9 | AppState.shared.createMenus()
10 | }
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Example/KeyboardShortcutsExample/AppState.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @MainActor
4 | final class AppState {
5 | static let shared = AppState()
6 |
7 | private init() {}
8 |
9 | func createMenus() {
10 | let testMenuItem = NSMenuItem()
11 | NSApp.mainMenu?.addItem(testMenuItem)
12 |
13 | let testMenu = NSMenu()
14 | testMenu.title = "Test"
15 | testMenuItem.submenu = testMenu
16 |
17 | testMenu.addCallbackItem("Shortcut 1") { [weak self] in
18 | self?.alert(1)
19 | }
20 | .setShortcut(for: .testShortcut1)
21 |
22 | testMenu.addCallbackItem("Shortcut 2") { [weak self] in
23 | self?.alert(2)
24 | }
25 | .setShortcut(for: .testShortcut2)
26 |
27 | testMenu.addCallbackItem("Shortcut 3") { [weak self] in
28 | self?.alert(3)
29 | }
30 | .setShortcut(for: .testShortcut3)
31 |
32 | testMenu.addCallbackItem("Shortcut 4") { [weak self] in
33 | self?.alert(4)
34 | }
35 | .setShortcut(for: .testShortcut4)
36 | }
37 |
38 | private func alert(_ number: Int) {
39 | let alert = NSAlert()
40 | alert.messageText = "Shortcut \(number) menu item action triggered!"
41 | alert.runModal()
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/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/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) {
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 | Divider()
61 | .padding(.vertical)
62 | Button("Reset All") {
63 | KeyboardShortcuts.resetAll()
64 | }
65 | }
66 | .frame(maxWidth: 300)
67 | .padding()
68 | .padding(.bottom, 20)
69 | .onChange(of: shortcut, initial: true) { oldValue, newValue in
70 | onShortcutChange(oldValue: oldValue, newValue: newValue)
71 | }
72 | }
73 |
74 | private func onShortcutChange(oldValue: Shortcut, newValue: Shortcut) {
75 | KeyboardShortcuts.disable(oldValue.name)
76 |
77 | KeyboardShortcuts.onKeyDown(for: newValue.name) {
78 | isPressed = true
79 | }
80 |
81 | KeyboardShortcuts.onKeyUp(for: newValue.name) {
82 | isPressed = false
83 | }
84 | }
85 | }
86 |
87 | private struct DoubleShortcut: View {
88 | @State private var isPressed1 = false
89 | @State private var isPressed2 = false
90 |
91 | var body: some View {
92 | Form {
93 | KeyboardShortcuts.Recorder("Shortcut 1:", name: .testShortcut1)
94 | .overlay(alignment: .trailing) {
95 | Text("Pressed? \(isPressed1 ? "👍" : "👎")")
96 | .offset(x: 90)
97 | }
98 | KeyboardShortcuts.Recorder(for: .testShortcut2) {
99 | Text("Shortcut 2:") // Intentionally using the verbose initializer for testing.
100 | }
101 | .overlay(alignment: .trailing) {
102 | Text("Pressed? \(isPressed2 ? "👍" : "👎")")
103 | .offset(x: 90)
104 | }
105 | Spacer()
106 | }
107 | .offset(x: -40)
108 | .frame(maxWidth: 300)
109 | .padding()
110 | .padding()
111 | .onGlobalKeyboardShortcut(.testShortcut1) {
112 | isPressed1 = $0 == .keyDown
113 | }
114 | .onGlobalKeyboardShortcut(.testShortcut2, type: .keyDown) {
115 | isPressed2 = true
116 | }
117 | .task {
118 | KeyboardShortcuts.onKeyUp(for: .testShortcut2) {
119 | isPressed2 = false
120 | }
121 | }
122 | }
123 | }
124 |
125 | struct MainScreen: View {
126 | var body: some View {
127 | VStack {
128 | DoubleShortcut()
129 | Divider()
130 | DynamicShortcut()
131 | }
132 | .frame(width: 400, height: 320)
133 | }
134 | }
135 |
136 | #Preview {
137 | MainScreen()
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 | @MainActor
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 | extension NSMenuItem {
53 | convenience init(
54 | _ title: String,
55 | action: Selector? = nil,
56 | key: String = "",
57 | keyModifiers: NSEvent.ModifierFlags? = nil,
58 | data: Any? = nil,
59 | isEnabled: Bool = true,
60 | isChecked: Bool = false,
61 | isHidden: Bool = false
62 | ) {
63 | self.init(title: title, action: action, keyEquivalent: key)
64 | self.representedObject = data
65 | self.isEnabled = isEnabled
66 | self.isChecked = isChecked
67 | self.isHidden = isHidden
68 |
69 | if let keyModifiers {
70 | self.keyEquivalentModifierMask = keyModifiers
71 | }
72 | }
73 |
74 | var isChecked: Bool {
75 | get { state == .on }
76 | set {
77 | state = newValue ? .on : .off
78 | }
79 | }
80 | }
81 |
82 | extension NSMenu {
83 | @MainActor
84 | @discardableResult
85 | func addCallbackItem(
86 | _ title: String,
87 | key: String = "",
88 | keyModifiers: NSEvent.ModifierFlags? = nil,
89 | isEnabled: Bool = true,
90 | isChecked: Bool = false,
91 | isHidden: Bool = false,
92 | action: @escaping () -> Void
93 | ) -> NSMenuItem {
94 | let menuItem = CallbackMenuItem(
95 | title,
96 | key: key,
97 | keyModifiers: keyModifiers,
98 | isEnabled: isEnabled,
99 | isChecked: isChecked,
100 | isHidden: isHidden,
101 | action: action
102 | )
103 | addItem(menuItem)
104 | return menuItem
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.11
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "KeyboardShortcuts",
6 | defaultLocalization: "en",
7 | platforms: [
8 | .macOS(.v10_15)
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 | #if os(macOS)
2 | import Carbon.HIToolbox
3 |
4 | private func carbonKeyboardShortcutsEventHandler(eventHandlerCall: EventHandlerCallRef?, event: EventRef?, userData: UnsafeMutableRawPointer?) -> OSStatus {
5 | CarbonKeyboardShortcuts.handleEvent(event)
6 | }
7 |
8 | enum CarbonKeyboardShortcuts {
9 | private final class HotKey {
10 | let shortcut: KeyboardShortcuts.Shortcut
11 | let carbonHotKeyId: Int
12 | var carbonHotKey: EventHotKeyRef?
13 | let onKeyDown: (KeyboardShortcuts.Shortcut) -> Void
14 | let onKeyUp: (KeyboardShortcuts.Shortcut) -> Void
15 |
16 | init(
17 | shortcut: KeyboardShortcuts.Shortcut,
18 | carbonHotKeyID: Int,
19 | carbonHotKey: EventHotKeyRef,
20 | onKeyDown: @escaping (KeyboardShortcuts.Shortcut) -> Void,
21 | onKeyUp: @escaping (KeyboardShortcuts.Shortcut) -> Void
22 | ) {
23 | self.shortcut = shortcut
24 | self.carbonHotKeyId = carbonHotKeyID
25 | self.carbonHotKey = carbonHotKey
26 | self.onKeyDown = onKeyDown
27 | self.onKeyUp = onKeyUp
28 | }
29 | }
30 |
31 | private static var hotKeys = [Int: HotKey]()
32 |
33 | // `SSKS` is just short for `Sindre Sorhus Keyboard Shortcuts`.
34 | // Using an integer now that `UTGetOSTypeFromString("SSKS" as CFString)` is deprecated.
35 | // swiftlint:disable:next number_separator
36 | private static let hotKeySignature: UInt32 = 1397967699 // OSType => "SSKS"
37 |
38 | private static var hotKeyId = 0
39 | private static var eventHandler: EventHandlerRef?
40 |
41 | private static let hotKeyEventTypes = [
42 | EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
43 | EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
44 | ]
45 | private static let rawKeyEventTypes = [
46 | EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventRawKeyDown)),
47 | EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventRawKeyUp))
48 | ]
49 |
50 | private static let keyEventMonitor = RunLoopLocalEventMonitor(events: [.keyDown, .keyUp], runLoopMode: .eventTracking) { event in
51 | guard
52 | let eventRef = OpaquePointer(event.eventRef),
53 | handleRawKeyEvent(eventRef) == noErr
54 | else {
55 | return event
56 | }
57 |
58 | return nil
59 | }
60 |
61 | private static func setUpEventHandlerIfNeeded() {
62 | guard
63 | eventHandler == nil,
64 | let dispatcher = GetEventDispatcherTarget()
65 | else {
66 | return
67 | }
68 |
69 | var handler: EventHandlerRef?
70 | let error = InstallEventHandler(
71 | dispatcher,
72 | carbonKeyboardShortcutsEventHandler,
73 | 0,
74 | nil,
75 | nil,
76 | &handler
77 | )
78 |
79 | guard
80 | error == noErr,
81 | let handler
82 | else {
83 | return
84 | }
85 |
86 | eventHandler = handler
87 |
88 | updateEventHandler()
89 | }
90 |
91 | static func updateEventHandler() {
92 | guard eventHandler != nil else {
93 | return
94 | }
95 |
96 | if KeyboardShortcuts.isEnabled {
97 | if KeyboardShortcuts.isMenuOpen {
98 | softUnregisterAll()
99 | RemoveEventTypesFromHandler(eventHandler, hotKeyEventTypes.count, hotKeyEventTypes)
100 |
101 | if #available(macOS 14, *) {
102 | keyEventMonitor.start()
103 | } else {
104 | AddEventTypesToHandler(eventHandler, rawKeyEventTypes.count, rawKeyEventTypes)
105 | }
106 | } else {
107 | softRegisterAll()
108 |
109 | if #available(macOS 14, *) {
110 | keyEventMonitor.stop()
111 | } else {
112 | RemoveEventTypesFromHandler(eventHandler, rawKeyEventTypes.count, rawKeyEventTypes)
113 | }
114 |
115 | AddEventTypesToHandler(eventHandler, hotKeyEventTypes.count, hotKeyEventTypes)
116 | }
117 | } else {
118 | softUnregisterAll()
119 | RemoveEventTypesFromHandler(eventHandler, hotKeyEventTypes.count, hotKeyEventTypes)
120 |
121 | if #available(macOS 14, *) {
122 | keyEventMonitor.stop()
123 | } else {
124 | RemoveEventTypesFromHandler(eventHandler, rawKeyEventTypes.count, rawKeyEventTypes)
125 | }
126 | }
127 | }
128 |
129 | static func register(
130 | _ shortcut: KeyboardShortcuts.Shortcut,
131 | onKeyDown: @escaping (KeyboardShortcuts.Shortcut) -> Void,
132 | onKeyUp: @escaping (KeyboardShortcuts.Shortcut) -> Void
133 | ) {
134 | hotKeyId += 1
135 |
136 | var eventHotKey: EventHotKeyRef?
137 | let registerError = RegisterEventHotKey(
138 | UInt32(shortcut.carbonKeyCode),
139 | UInt32(shortcut.carbonModifiers),
140 | EventHotKeyID(signature: hotKeySignature, id: UInt32(hotKeyId)),
141 | GetEventDispatcherTarget(),
142 | 0,
143 | &eventHotKey
144 | )
145 |
146 | guard
147 | registerError == noErr,
148 | let carbonHotKey = eventHotKey
149 | else {
150 | print("Error registering hotkey \(shortcut):", registerError)
151 | return
152 | }
153 |
154 | hotKeys[hotKeyId] = HotKey(
155 | shortcut: shortcut,
156 | carbonHotKeyID: hotKeyId,
157 | carbonHotKey: carbonHotKey,
158 | onKeyDown: onKeyDown,
159 | onKeyUp: onKeyUp
160 | )
161 |
162 | setUpEventHandlerIfNeeded()
163 | }
164 |
165 | private static func softRegisterAll() {
166 | for hotKey in hotKeys.values {
167 | guard hotKey.carbonHotKey == nil else {
168 | continue
169 | }
170 |
171 | var eventHotKey: EventHotKeyRef?
172 | let error = RegisterEventHotKey(
173 | UInt32(hotKey.shortcut.carbonKeyCode),
174 | UInt32(hotKey.shortcut.carbonModifiers),
175 | EventHotKeyID(signature: hotKeySignature, id: UInt32(hotKey.carbonHotKeyId)),
176 | GetEventDispatcherTarget(),
177 | 0,
178 | &eventHotKey
179 | )
180 |
181 | guard
182 | error == noErr,
183 | let eventHotKey
184 | else {
185 | print("Error registering hotkey \(hotKey.shortcut):", error)
186 | hotKeys.removeValue(forKey: hotKey.carbonHotKeyId)
187 | continue
188 | }
189 |
190 | hotKey.carbonHotKey = eventHotKey
191 | }
192 | }
193 |
194 | private static func unregisterHotKey(_ hotKey: HotKey) {
195 | UnregisterEventHotKey(hotKey.carbonHotKey)
196 | hotKeys.removeValue(forKey: hotKey.carbonHotKeyId)
197 | }
198 |
199 | static func unregister(_ shortcut: KeyboardShortcuts.Shortcut) {
200 | for hotKey in hotKeys.values where hotKey.shortcut == shortcut {
201 | unregisterHotKey(hotKey)
202 | }
203 | }
204 |
205 | static func unregisterAll() {
206 | for hotKey in hotKeys.values {
207 | unregisterHotKey(hotKey)
208 | }
209 | }
210 |
211 | private static func softUnregisterAll() {
212 | for hotKey in hotKeys.values {
213 | UnregisterEventHotKey(hotKey.carbonHotKey)
214 | hotKey.carbonHotKey = nil
215 | }
216 | }
217 |
218 | fileprivate static func handleEvent(_ event: EventRef?) -> OSStatus {
219 | guard let event else {
220 | return OSStatus(eventNotHandledErr)
221 | }
222 |
223 | switch Int(GetEventKind(event)) {
224 | case kEventHotKeyPressed, kEventHotKeyReleased:
225 | return handleHotKeyEvent(event)
226 | case kEventRawKeyDown, kEventRawKeyUp:
227 | return handleRawKeyEvent(event)
228 | default:
229 | break
230 | }
231 |
232 | return OSStatus(eventNotHandledErr)
233 | }
234 |
235 | private static func handleHotKeyEvent(_ event: EventRef) -> OSStatus {
236 | var eventHotKeyId = EventHotKeyID()
237 | let error = GetEventParameter(
238 | event,
239 | UInt32(kEventParamDirectObject),
240 | UInt32(typeEventHotKeyID),
241 | nil,
242 | MemoryLayout.size,
243 | nil,
244 | &eventHotKeyId
245 | )
246 |
247 | guard error == noErr else {
248 | return error
249 | }
250 |
251 | guard
252 | eventHotKeyId.signature == hotKeySignature,
253 | let hotKey = hotKeys[Int(eventHotKeyId.id)]
254 | else {
255 | return OSStatus(eventNotHandledErr)
256 | }
257 |
258 | switch Int(GetEventKind(event)) {
259 | case kEventHotKeyPressed:
260 | hotKey.onKeyDown(hotKey.shortcut)
261 | return noErr
262 | case kEventHotKeyReleased:
263 | hotKey.onKeyUp(hotKey.shortcut)
264 | return noErr
265 | default:
266 | break
267 | }
268 |
269 | return OSStatus(eventNotHandledErr)
270 | }
271 |
272 | private static func handleRawKeyEvent(_ event: EventRef) -> OSStatus {
273 | var eventKeyCode = UInt32()
274 | let keyCodeError = GetEventParameter(
275 | event,
276 | UInt32(kEventParamKeyCode),
277 | typeUInt32,
278 | nil,
279 | MemoryLayout.size,
280 | nil,
281 | &eventKeyCode
282 | )
283 |
284 | guard keyCodeError == noErr else {
285 | return keyCodeError
286 | }
287 |
288 | var eventKeyModifiers = UInt32()
289 | let keyModifiersError = GetEventParameter(
290 | event,
291 | UInt32(kEventParamKeyModifiers),
292 | typeUInt32,
293 | nil,
294 | MemoryLayout.size,
295 | nil,
296 | &eventKeyModifiers
297 | )
298 |
299 | guard keyModifiersError == noErr else {
300 | return keyModifiersError
301 | }
302 |
303 | let shortcut = KeyboardShortcuts.Shortcut(carbonKeyCode: Int(eventKeyCode), carbonModifiers: Int(eventKeyModifiers))
304 |
305 | guard let hotKey = (hotKeys.values.first { $0.shortcut == shortcut }) else {
306 | return OSStatus(eventNotHandledErr)
307 | }
308 |
309 | switch Int(GetEventKind(event)) {
310 | case kEventRawKeyDown:
311 | hotKey.onKeyDown(hotKey.shortcut)
312 | return noErr
313 | case kEventRawKeyUp:
314 | hotKey.onKeyUp(hotKey.shortcut)
315 | return noErr
316 | default:
317 | break
318 | }
319 |
320 | return OSStatus(eventNotHandledErr)
321 | }
322 | }
323 |
324 | extension CarbonKeyboardShortcuts {
325 | static var system: [KeyboardShortcuts.Shortcut] {
326 | var shortcutsUnmanaged: Unmanaged?
327 | guard
328 | CopySymbolicHotKeys(&shortcutsUnmanaged) == noErr,
329 | let shortcuts = shortcutsUnmanaged?.takeRetainedValue() as? [[String: Any]]
330 | else {
331 | assertionFailure("Could not get system keyboard shortcuts")
332 | return []
333 | }
334 |
335 | return shortcuts.compactMap {
336 | guard
337 | ($0[kHISymbolicHotKeyEnabled] as? Bool) == true,
338 | let carbonKeyCode = $0[kHISymbolicHotKeyCode] as? Int,
339 | let carbonModifiers = $0[kHISymbolicHotKeyModifiers] as? Int
340 | else {
341 | return nil
342 | }
343 |
344 | return KeyboardShortcuts.Shortcut(
345 | carbonKeyCode: carbonKeyCode,
346 | carbonModifiers: carbonModifiers
347 | )
348 | }
349 | }
350 | }
351 | #endif
352 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Key.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
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, Sendable {
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 | #endif
197 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/KeyboardShortcuts.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import AppKit.NSMenu
3 |
4 | /**
5 | Global keyboard shortcuts for your macOS app.
6 | */
7 | public enum KeyboardShortcuts {
8 | private static var registeredShortcuts = Set()
9 |
10 | private static var legacyKeyDownHandlers = [Name: [() -> Void]]()
11 | private static var legacyKeyUpHandlers = [Name: [() -> Void]]()
12 |
13 | private static var streamKeyDownHandlers = [Name: [UUID: () -> Void]]()
14 | private static var streamKeyUpHandlers = [Name: [UUID: () -> Void]]()
15 |
16 | private static var shortcutsForLegacyHandlers: Set {
17 | let shortcuts = [legacyKeyDownHandlers.keys, legacyKeyUpHandlers.keys]
18 | .flatMap { $0 }
19 | .compactMap(\.shortcut)
20 |
21 | return Set(shortcuts)
22 | }
23 |
24 | private static var shortcutsForStreamHandlers: Set {
25 | let shortcuts = [streamKeyDownHandlers.keys, streamKeyUpHandlers.keys]
26 | .flatMap { $0 }
27 | .compactMap(\.shortcut)
28 |
29 | return Set(shortcuts)
30 | }
31 |
32 | private static var shortcutsForHandlers: Set {
33 | shortcutsForLegacyHandlers.union(shortcutsForStreamHandlers)
34 | }
35 |
36 | private static var isInitialized = false
37 |
38 | private static var openMenuObserver: NSObjectProtocol?
39 | private static var closeMenuObserver: NSObjectProtocol?
40 |
41 | /**
42 | When `true`, event handlers will not be called for registered keyboard shortcuts.
43 | */
44 | static var isPaused = false
45 |
46 | /**
47 | Enable/disable monitoring of all keyboard shortcuts.
48 |
49 | The default is `true`.
50 | */
51 | public static var isEnabled = true {
52 | didSet {
53 | guard isEnabled != oldValue else {
54 | return
55 | }
56 |
57 | CarbonKeyboardShortcuts.updateEventHandler()
58 | }
59 | }
60 |
61 | static var allNames: Set {
62 | UserDefaults.standard.dictionaryRepresentation()
63 | .compactMap { key, _ in
64 | guard key.hasPrefix(userDefaultsPrefix) else {
65 | return nil
66 | }
67 |
68 | let name = key.replacingPrefix(userDefaultsPrefix, with: "")
69 | return .init(name)
70 | }
71 | .toSet()
72 | }
73 |
74 | /**
75 | Enable keyboard shortcuts to work even when an `NSMenu` is open by setting this property when the menu opens and closes.
76 |
77 | `NSMenu` runs in a tracking run mode that blocks keyboard shortcuts events. When you set this property to `true`, it switches to a different kind of event handler, which does work when the menu is open.
78 |
79 | The main use-case for this is toggling the menu of a menu bar app with a keyboard shortcut.
80 | */
81 | private(set) static var isMenuOpen = false {
82 | didSet {
83 | guard isMenuOpen != oldValue else {
84 | return
85 | }
86 |
87 | CarbonKeyboardShortcuts.updateEventHandler()
88 | }
89 | }
90 |
91 | private static func register(_ shortcut: Shortcut) {
92 | guard !registeredShortcuts.contains(shortcut) else {
93 | return
94 | }
95 |
96 | CarbonKeyboardShortcuts.register(
97 | shortcut,
98 | onKeyDown: handleOnKeyDown,
99 | onKeyUp: handleOnKeyUp
100 | )
101 |
102 | registeredShortcuts.insert(shortcut)
103 | }
104 |
105 | /**
106 | Register the shortcut for the given name if it has a shortcut.
107 | */
108 | private static func registerShortcutIfNeeded(for name: Name) {
109 | guard let shortcut = getShortcut(for: name) else {
110 | return
111 | }
112 |
113 | register(shortcut)
114 | }
115 |
116 | private static func unregister(_ shortcut: Shortcut) {
117 | CarbonKeyboardShortcuts.unregister(shortcut)
118 | registeredShortcuts.remove(shortcut)
119 | }
120 |
121 | /**
122 | Unregister the given shortcut if it has no handlers.
123 | */
124 | private static func unregisterIfNeeded(_ shortcut: Shortcut) {
125 | guard !shortcutsForHandlers.contains(shortcut) else {
126 | return
127 | }
128 |
129 | unregister(shortcut)
130 | }
131 |
132 | /**
133 | Unregister the shortcut for the given name if it has no handlers.
134 | */
135 | private static func unregisterShortcutIfNeeded(for name: Name) {
136 | guard let shortcut = name.shortcut else {
137 | return
138 | }
139 |
140 | unregisterIfNeeded(shortcut)
141 | }
142 |
143 | private static func unregisterAll() {
144 | CarbonKeyboardShortcuts.unregisterAll()
145 | registeredShortcuts.removeAll()
146 |
147 | // TODO: Should remove user defaults too.
148 | }
149 |
150 | static func initialize() {
151 | guard !isInitialized else {
152 | return
153 | }
154 |
155 | openMenuObserver = NotificationCenter.default.addObserver(forName: NSMenu.didBeginTrackingNotification, object: nil, queue: nil) { _ in
156 | isMenuOpen = true
157 | }
158 |
159 | closeMenuObserver = NotificationCenter.default.addObserver(forName: NSMenu.didEndTrackingNotification, object: nil, queue: nil) { _ in
160 | isMenuOpen = false
161 | }
162 |
163 | isInitialized = true
164 | }
165 |
166 | /**
167 | Remove all handlers receiving keyboard shortcuts events.
168 |
169 | This can be used to reset the handlers before re-creating them to avoid having multiple handlers for the same shortcut.
170 |
171 | - Note: This method does not affect listeners using ``events(for:)``.
172 | */
173 | public static func removeAllHandlers() {
174 | let shortcutsToUnregister = shortcutsForLegacyHandlers.subtracting(shortcutsForStreamHandlers)
175 |
176 | for shortcut in shortcutsToUnregister {
177 | unregister(shortcut)
178 | }
179 |
180 | legacyKeyDownHandlers = [:]
181 | legacyKeyUpHandlers = [:]
182 | }
183 |
184 | /**
185 | Remove the keyboard shortcut handler for the given name.
186 |
187 | This can be used to reset the handler before re-creating it to avoid having multiple handlers for the same shortcut.
188 |
189 | - Parameter name: The name of the keyboard shortcut to remove handlers for.
190 |
191 | - Note: This method does not affect listeners using ``events(for:)``.
192 | */
193 | public static func removeHandler(for name: Name) {
194 | legacyKeyDownHandlers[name] = nil
195 | legacyKeyUpHandlers[name] = nil
196 |
197 | // Make sure not to unregister stream handlers.
198 | guard
199 | let shortcut = getShortcut(for: name),
200 | !shortcutsForStreamHandlers.contains(shortcut)
201 | else {
202 | return
203 | }
204 |
205 | unregister(shortcut)
206 | }
207 |
208 | /**
209 | Returns whether the keyboard shortcut for the given name is enabled.
210 |
211 | This checks if the shortcut is registered and will trigger handlers. It respects the global ``isEnabled``.
212 |
213 | ```swift
214 | let isEnabled = KeyboardShortcuts.isEnabled(for: .toggleUnicornMode)
215 | ```
216 |
217 | - Tip: Use ``disable(_:)-(Name...)`` and ``enable(_:)-(Name...)`` to change the status.
218 | */
219 | public static func isEnabled(for name: Name) -> Bool {
220 | guard
221 | isEnabled,
222 | let shortcut = getShortcut(for: name)
223 | else {
224 | return false
225 | }
226 |
227 | return registeredShortcuts.contains(shortcut)
228 | }
229 |
230 | /**
231 | Disable the keyboard shortcut for one or more names.
232 | */
233 | public static func disable(_ names: [Name]) {
234 | for name in names {
235 | guard let shortcut = getShortcut(for: name) else {
236 | continue
237 | }
238 |
239 | unregister(shortcut)
240 | }
241 | }
242 |
243 | /**
244 | Disable the keyboard shortcut for one or more names.
245 | */
246 | public static func disable(_ names: Name...) {
247 | disable(names)
248 | }
249 |
250 | /**
251 | Enable the keyboard shortcut for one or more names.
252 | */
253 | public static func enable(_ names: [Name]) {
254 | for name in names {
255 | guard let shortcut = getShortcut(for: name) else {
256 | continue
257 | }
258 |
259 | register(shortcut)
260 | }
261 | }
262 |
263 | /**
264 | Enable the keyboard shortcut for one or more names.
265 | */
266 | public static func enable(_ names: Name...) {
267 | enable(names)
268 | }
269 |
270 | /**
271 | Reset the keyboard shortcut for one or more names.
272 |
273 | If the `Name` has a default shortcut, it will reset to that.
274 |
275 | - Note: This overload exists as Swift doesn't support splatting.
276 |
277 | ```swift
278 | import SwiftUI
279 | import KeyboardShortcuts
280 |
281 | struct SettingsScreen: View {
282 | var body: some View {
283 | VStack {
284 | // …
285 | Button("Reset") {
286 | KeyboardShortcuts.reset(.toggleUnicornMode)
287 | }
288 | }
289 | }
290 | }
291 | ```
292 | */
293 | public static func reset(_ names: [Name]) {
294 | for name in names {
295 | setShortcut(name.defaultShortcut, for: name)
296 | }
297 | }
298 |
299 | /**
300 | Reset the keyboard shortcut for one or more names.
301 |
302 | If the `Name` has a default shortcut, it will reset to that.
303 |
304 | ```swift
305 | import SwiftUI
306 | import KeyboardShortcuts
307 |
308 | struct SettingsScreen: View {
309 | var body: some View {
310 | VStack {
311 | // …
312 | Button("Reset") {
313 | KeyboardShortcuts.reset(.toggleUnicornMode)
314 | }
315 | }
316 | }
317 | }
318 | ```
319 | */
320 | public static func reset(_ names: Name...) {
321 | reset(names)
322 | }
323 |
324 | /**
325 | Reset the keyboard shortcut for all the names.
326 |
327 | Unlike `reset(…)`, this resets all the shortcuts to `nil`, not the `defaultValue`.
328 |
329 | ```swift
330 | import SwiftUI
331 | import KeyboardShortcuts
332 |
333 | struct SettingsScreen: View {
334 | var body: some View {
335 | VStack {
336 | // …
337 | Button("Reset All") {
338 | KeyboardShortcuts.resetAll()
339 | }
340 | }
341 | }
342 | }
343 | ```
344 | */
345 | public static func resetAll() {
346 | reset(allNames.toArray())
347 | }
348 |
349 | /**
350 | Set the keyboard shortcut for a name.
351 |
352 | 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.
353 |
354 | 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.
355 | */
356 | public static func setShortcut(_ shortcut: Shortcut?, for name: Name) {
357 | if let shortcut {
358 | userDefaultsSet(name: name, shortcut: shortcut)
359 | } else {
360 | if name.defaultShortcut != nil {
361 | userDefaultsDisable(name: name)
362 | } else {
363 | userDefaultsRemove(name: name)
364 | }
365 | }
366 | }
367 |
368 | /**
369 | Get the keyboard shortcut for a name.
370 | */
371 | public static func getShortcut(for name: Name) -> Shortcut? {
372 | guard
373 | let data = UserDefaults.standard.string(forKey: userDefaultsKey(for: name))?.data(using: .utf8),
374 | let decoded = try? JSONDecoder().decode(Shortcut.self, from: data)
375 | else {
376 | return nil
377 | }
378 |
379 | return decoded
380 | }
381 |
382 | private static func handleOnKeyDown(_ shortcut: Shortcut) {
383 | guard !isPaused else {
384 | return
385 | }
386 |
387 | for (name, handlers) in legacyKeyDownHandlers {
388 | guard getShortcut(for: name) == shortcut else {
389 | continue
390 | }
391 |
392 | for handler in handlers {
393 | handler()
394 | }
395 | }
396 |
397 | for (name, handlers) in streamKeyDownHandlers {
398 | guard getShortcut(for: name) == shortcut else {
399 | continue
400 | }
401 |
402 | for handler in handlers.values {
403 | handler()
404 | }
405 | }
406 | }
407 |
408 | private static func handleOnKeyUp(_ shortcut: Shortcut) {
409 | guard !isPaused else {
410 | return
411 | }
412 |
413 | for (name, handlers) in legacyKeyUpHandlers {
414 | guard getShortcut(for: name) == shortcut else {
415 | continue
416 | }
417 |
418 | for handler in handlers {
419 | handler()
420 | }
421 | }
422 |
423 | for (name, handlers) in streamKeyUpHandlers {
424 | guard getShortcut(for: name) == shortcut else {
425 | continue
426 | }
427 |
428 | for handler in handlers.values {
429 | handler()
430 | }
431 | }
432 | }
433 |
434 | /**
435 | Listen to the keyboard shortcut with the given name being pressed.
436 |
437 | You can register multiple listeners.
438 |
439 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
440 |
441 | - Important: This will be deprecated in the future. Prefer ``events(for:)`` for new code.
442 |
443 | ```swift
444 | import AppKit
445 | import KeyboardShortcuts
446 |
447 | @main
448 | final class AppDelegate: NSObject, NSApplicationDelegate {
449 | func applicationDidFinishLaunching(_ notification: Notification) {
450 | KeyboardShortcuts.onKeyDown(for: .toggleUnicornMode) { [self] in
451 | isUnicornMode.toggle()
452 | }
453 | }
454 | }
455 | ```
456 | */
457 | public static func onKeyDown(for name: Name, action: @escaping () -> Void) {
458 | legacyKeyDownHandlers[name, default: []].append(action)
459 | registerShortcutIfNeeded(for: name)
460 | }
461 |
462 | /**
463 | Listen to the keyboard shortcut with the given name being pressed.
464 |
465 | You can register multiple listeners.
466 |
467 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
468 |
469 | - Important: This will be deprecated in the future. Prefer ``events(for:)`` for new code.
470 |
471 | ```swift
472 | import AppKit
473 | import KeyboardShortcuts
474 |
475 | @main
476 | final class AppDelegate: NSObject, NSApplicationDelegate {
477 | func applicationDidFinishLaunching(_ notification: Notification) {
478 | KeyboardShortcuts.onKeyUp(for: .toggleUnicornMode) { [self] in
479 | isUnicornMode.toggle()
480 | }
481 | }
482 | }
483 | ```
484 | */
485 | public static func onKeyUp(for name: Name, action: @escaping () -> Void) {
486 | legacyKeyUpHandlers[name, default: []].append(action)
487 | registerShortcutIfNeeded(for: name)
488 | }
489 |
490 | private static let userDefaultsPrefix = "KeyboardShortcuts_"
491 |
492 | private static func userDefaultsKey(for shortcutName: Name) -> String { "\(userDefaultsPrefix)\(shortcutName.rawValue)"
493 | }
494 |
495 | static func userDefaultsDidChange(name: Name) {
496 | // TODO: Use proper UserDefaults observation instead of this.
497 | NotificationCenter.default.post(name: .shortcutByNameDidChange, object: nil, userInfo: ["name": name])
498 | }
499 |
500 | static func userDefaultsSet(name: Name, shortcut: Shortcut) {
501 | guard let encoded = try? JSONEncoder().encode(shortcut).toString else {
502 | return
503 | }
504 |
505 | if let oldShortcut = getShortcut(for: name) {
506 | unregister(oldShortcut)
507 | }
508 |
509 | register(shortcut)
510 | UserDefaults.standard.set(encoded, forKey: userDefaultsKey(for: name))
511 | userDefaultsDidChange(name: name)
512 | }
513 |
514 | static func userDefaultsDisable(name: Name) {
515 | guard let shortcut = getShortcut(for: name) else {
516 | return
517 | }
518 |
519 | UserDefaults.standard.set(false, forKey: userDefaultsKey(for: name))
520 | unregister(shortcut)
521 | userDefaultsDidChange(name: name)
522 | }
523 |
524 | static func userDefaultsRemove(name: Name) {
525 | guard let shortcut = getShortcut(for: name) else {
526 | return
527 | }
528 |
529 | UserDefaults.standard.removeObject(forKey: userDefaultsKey(for: name))
530 | unregister(shortcut)
531 | userDefaultsDidChange(name: name)
532 | }
533 |
534 | static func userDefaultsContains(name: Name) -> Bool {
535 | UserDefaults.standard.object(forKey: userDefaultsKey(for: name)) != nil
536 | }
537 | }
538 |
539 | extension KeyboardShortcuts {
540 | public enum EventType: Sendable {
541 | case keyDown
542 | case keyUp
543 | }
544 |
545 | /**
546 | Listen to the keyboard shortcut with the given name being pressed.
547 |
548 | You can register multiple listeners.
549 |
550 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
551 |
552 | Ending the async sequence will stop the listener. For example, in the below example, the listener will stop when the view disappears.
553 |
554 | ```swift
555 | import SwiftUI
556 | import KeyboardShortcuts
557 |
558 | struct ContentView: View {
559 | @State private var isUnicornMode = false
560 |
561 | var body: some View {
562 | Text(isUnicornMode ? "🦄" : "🐴")
563 | .task {
564 | for await event in KeyboardShortcuts.events(for: .toggleUnicornMode) where event == .keyUp {
565 | isUnicornMode.toggle()
566 | }
567 | }
568 | }
569 | }
570 | ```
571 |
572 | - Note: This method is not affected by `.removeAllHandlers()`.
573 | */
574 | public static func events(for name: Name) -> AsyncStream {
575 | AsyncStream { continuation in
576 | let id = UUID()
577 |
578 | DispatchQueue.main.async {
579 | streamKeyDownHandlers[name, default: [:]][id] = {
580 | continuation.yield(.keyDown)
581 | }
582 |
583 | streamKeyUpHandlers[name, default: [:]][id] = {
584 | continuation.yield(.keyUp)
585 | }
586 |
587 | registerShortcutIfNeeded(for: name)
588 | }
589 |
590 | continuation.onTermination = { _ in
591 | DispatchQueue.main.async {
592 | streamKeyDownHandlers[name]?[id] = nil
593 | streamKeyUpHandlers[name]?[id] = nil
594 |
595 | unregisterShortcutIfNeeded(for: name)
596 | }
597 | }
598 | }
599 | }
600 |
601 | /**
602 | Listen to keyboard shortcut events with the given name and type.
603 |
604 | You can register multiple listeners.
605 |
606 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
607 |
608 | Ending the async sequence will stop the listener. For example, in the below example, the listener will stop when the view disappears.
609 |
610 | ```swift
611 | import SwiftUI
612 | import KeyboardShortcuts
613 |
614 | struct ContentView: View {
615 | @State private var isUnicornMode = false
616 |
617 | var body: some View {
618 | Text(isUnicornMode ? "🦄" : "🐴")
619 | .task {
620 | for await event in KeyboardShortcuts.events(for: .toggleUnicornMode) where event == .keyUp {
621 | isUnicornMode.toggle()
622 | }
623 | }
624 | }
625 | }
626 | ```
627 |
628 | - Note: This method is not affected by `.removeAllHandlers()`.
629 | */
630 | public static func events(_ type: EventType, for name: Name) -> AsyncFilterSequence> {
631 | events(for: name).filter { $0 == type }
632 | }
633 | }
634 |
635 | extension Notification.Name {
636 | static let shortcutByNameDidChange = Self("KeyboardShortcuts_shortcutByNameDidChange")
637 | }
638 | #endif
639 |
--------------------------------------------------------------------------------
/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 | "keyboard_shortcut_disallowed" = "يجب دمج مفتاح Option مع Command أو Control.";
7 | "ok" = "موافق";
8 | "space_key" = "مسافة";
9 |
--------------------------------------------------------------------------------
/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 | "keyboard_shortcut_disallowed" = "Modifikátor Option musí být kombinován s klávesou Command nebo Control.";
7 | "ok" = "OK";
8 | "space_key" = "Mezera";
9 |
--------------------------------------------------------------------------------
/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 › Tastaturkurzbefehle“ geändert werden.";
6 | "keyboard_shortcut_disallowed" = "Die Option-Taste muss mit der Befehlstaste oder der Steuerungstaste kombiniert werden.";
7 | "ok" = "OK";
8 | "space_key" = "Leer";
9 |
--------------------------------------------------------------------------------
/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 | "keyboard_shortcut_disallowed" = "Option modifier must be combined with Command or Control.";
7 | "force_use_shortcut" = "Use Anyway";
8 | "ok" = "OK";
9 | "space_key" = "Space";
10 |
--------------------------------------------------------------------------------
/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 | "keyboard_shortcut_disallowed" = "El modificador Option debe combinarse con Command o Control.";
7 | "ok" = "Aceptar";
8 | "space_key" = "Espacio";
9 |
--------------------------------------------------------------------------------
/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 | "keyboard_shortcut_disallowed" = "Le modificateur Option doit être combiné avec Command ou Control.";
7 | "ok" = "OK";
8 | "space_key" = "Espace";
9 |
--------------------------------------------------------------------------------
/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 | "keyboard_shortcut_disallowed" = "Az Option módosítót a Command vagy Control billentyűvel együtt kell használni.";
7 | "ok" = "OK";
8 | "space_key" = "Szóköz";
9 |
--------------------------------------------------------------------------------
/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 | "keyboard_shortcut_disallowed" = "Optionキーは、CommandキーまたはControlキーと組み合わせる必要があります。";
7 | "force_use_shortcut" = "強制使用";
8 | "ok" = "OK";
9 | "space_key" = "スペース";
10 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/ko.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 | "keyboard_shortcut_disallowed" = "Option 수정자는 Command 또는 Control과 함께 사용해야 합니다.";
7 | "ok" = "확인";
8 | "space_key" = "빈칸";
9 |
--------------------------------------------------------------------------------
/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 | "keyboard_shortcut_disallowed" = "De Option-toets moet worden gecombineerd met Command of Control.";
7 | "ok" = "OK";
8 | "space_key" = "spatie";
9 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/pt-BR.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "Gravar Atalho";
2 | "press_shortcut" = "Digite o Atalho";
3 | "keyboard_shortcut_used_by_menu_item" = "Este atalho não pode ser usado porque ele já é usado pelo item de menu “%@”.";
4 | "keyboard_shortcut_used_by_system" = "Este atalho não pode ser usado porque ele já é usado por um atalho do sistema.";
5 | "keyboard_shortcuts_can_be_changed" = "A maioria dos atalhos do sistema podem ser alterados em “Ajustes do Sistema › Teclado › Atalhos de Teclado”.";
6 | "keyboard_shortcut_disallowed" = "O modificador Option deve ser combinado com Command ou Control.";
7 | "ok" = "OK";
8 | "space_key" = "Espaço";
9 |
--------------------------------------------------------------------------------
/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 | "keyboard_shortcut_disallowed" = "Клавиша Option должна использоваться в сочетании с Command или Control.";
7 | "ok" = "OK";
8 | "space_key" = "Пробел";
9 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Localization/sk.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "record_shortcut" = "Nahraj skratku";
2 | "press_shortcut" = "Stlač skratku";
3 | "keyboard_shortcut_used_by_menu_item" = "Túto klávesovú skratku nemožno použiť, pretože ju už používa položka menu “%@”.";
4 | "keyboard_shortcut_used_by_system" = "Túto klávesovú skratku nemožno použiť, pretože je už použivaná pre celý systém.";
5 | "keyboard_shortcuts_can_be_changed" = "Väčšinu systémových klávesových skratiek môžeš zmeniť v “Systémové nastavenia › Klávesnica › Klávesové skratky”.";
6 | "keyboard_shortcut_disallowed" = "Modifikátor Option musí byť kombinovaný s klávesmi Command alebo Control.";
7 | "force_use_shortcut" = "Použiť napriek tomu";
8 | "ok" = "OK";
9 | "space_key" = "Medzerník";
10 |
--------------------------------------------------------------------------------
/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 | "keyboard_shortcut_disallowed" = "Option键必须与Command键或Control键组合使用。";
7 | "force_use_shortcut" = "强制使用";
8 | "ok" = "好";
9 | "space_key" = "空格";
10 |
--------------------------------------------------------------------------------
/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 | "keyboard_shortcut_disallowed" = "Option鍵必須與Command鍵或Control鍵組合使用。";
7 | "force_use_shortcut" = "強制使用";
8 | "ok" = "好";
9 | "space_key" = "空格";
10 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/NSMenuItem++.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import AppKit
3 |
4 | extension NSMenuItem {
5 | private enum AssociatedKeys {
6 | @MainActor
7 | static let observer = ObjectAssociation()
8 | }
9 |
10 | @MainActor
11 | private func clearShortcut() {
12 | keyEquivalent = ""
13 | keyEquivalentModifierMask = []
14 |
15 | if #available(macOS 12, *) {
16 | allowsAutomaticKeyEquivalentLocalization = true
17 | }
18 | }
19 |
20 | // TODO: Make this a getter/setter. We must first add the ability to create a `Shortcut` from a `keyEquivalent`.
21 | /**
22 | Show a recorded keyboard shortcut in a `NSMenuItem`.
23 |
24 | The menu item will automatically be kept up to date with changes to the keyboard shortcut.
25 |
26 | Pass in `nil` to clear the keyboard shortcut.
27 |
28 | This method overrides `.keyEquivalent` and `.keyEquivalentModifierMask`.
29 |
30 | ```swift
31 | import AppKit
32 | import KeyboardShortcuts
33 |
34 | extension KeyboardShortcuts.Name {
35 | static let toggleUnicornMode = Self("toggleUnicornMode")
36 | }
37 |
38 | // … `Recorder` logic for recording the keyboard shortcut …
39 |
40 | let menuItem = NSMenuItem()
41 | menuItem.title = "Toggle Unicorn Mode"
42 | menuItem.setShortcut(for: .toggleUnicornMode)
43 | ```
44 |
45 | 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.
46 |
47 | - 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`.
48 | */
49 | @MainActor
50 | public func setShortcut(for name: KeyboardShortcuts.Name?) {
51 | guard let name else {
52 | clearShortcut()
53 | NotificationCenter.default.removeObserver(AssociatedKeys.observer[self] as Any)
54 | AssociatedKeys.observer[self] = nil
55 | return
56 | }
57 |
58 | func set() {
59 | let shortcut = KeyboardShortcuts.Shortcut(name: name)
60 | setShortcut(shortcut)
61 | }
62 |
63 | set()
64 |
65 | // TODO: Use AsyncStream when targeting macOS 15.
66 | AssociatedKeys.observer[self] = NotificationCenter.default.addObserver(forName: .shortcutByNameDidChange, object: nil, queue: nil) { notification in
67 | guard
68 | let nameInNotification = notification.userInfo?["name"] as? KeyboardShortcuts.Name,
69 | nameInNotification == name
70 | else {
71 | return
72 | }
73 |
74 | DispatchQueue.main.async { // TODO: Use `Task { @MainActor`
75 | set()
76 | }
77 | }
78 | }
79 |
80 | /**
81 | Add a keyboard shortcut to a `NSMenuItem`.
82 |
83 | 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.
84 |
85 | Pass in `nil` to clear the keyboard shortcut.
86 |
87 | This method overrides `.keyEquivalent` and `.keyEquivalentModifierMask`.
88 |
89 | - 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`.
90 | */
91 | @_disfavoredOverload
92 | @MainActor
93 | public func setShortcut(_ shortcut: KeyboardShortcuts.Shortcut?) {
94 | guard let shortcut else {
95 | clearShortcut()
96 | return
97 | }
98 |
99 | keyEquivalent = shortcut.nsMenuItemKeyEquivalent ?? ""
100 | keyEquivalentModifierMask = shortcut.modifiers
101 |
102 | if #available(macOS 12, *) {
103 | allowsAutomaticKeyEquivalentLocalization = false
104 | }
105 | }
106 | }
107 | #endif
108 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Name.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | extension KeyboardShortcuts {
3 | /**
4 | The strongly-typed name of the keyboard shortcut.
5 |
6 | After registering it, you can use it in, for example, `KeyboardShortcut.Recorder` and `KeyboardShortcut.onKeyUp()`.
7 |
8 | ```swift
9 | import KeyboardShortcuts
10 |
11 | extension KeyboardShortcuts.Name {
12 | static let toggleUnicornMode = Self("toggleUnicornMode")
13 | }
14 | ```
15 | */
16 | public struct Name: Hashable, Sendable {
17 | // This makes it possible to use `Shortcut` without the namespace.
18 | /// :nodoc:
19 | public typealias Shortcut = KeyboardShortcuts.Shortcut
20 |
21 | public let rawValue: String
22 | public let defaultShortcut: Shortcut?
23 |
24 | /**
25 | The keyboard shortcut assigned to the name.
26 | */
27 | public var shortcut: Shortcut? {
28 | get { KeyboardShortcuts.getShortcut(for: self) }
29 | nonmutating set {
30 | KeyboardShortcuts.setShortcut(newValue, for: self)
31 | }
32 | }
33 |
34 | /**
35 | - Parameter name: Name of the shortcut.
36 | - Parameter initialShortcut: 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.
37 | */
38 | public init(_ name: String, default initialShortcut: Shortcut? = nil) {
39 | self.rawValue = name
40 | self.defaultShortcut = initialShortcut
41 |
42 | if
43 | let initialShortcut,
44 | !userDefaultsContains(name: self)
45 | {
46 | setShortcut(initialShortcut, for: self)
47 | }
48 |
49 | KeyboardShortcuts.initialize()
50 | }
51 | }
52 | }
53 |
54 | extension KeyboardShortcuts.Name: RawRepresentable {
55 | /// :nodoc:
56 | public init?(rawValue: String) {
57 | self.init(rawValue)
58 | }
59 | }
60 | #endif
61 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Recorder.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import SwiftUI
3 |
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 | - Note: Since macOS 15, for sandboxed apps, it's [no longer possible](https://developer.apple.com/forums/thread/763878?answerId=804374022#804374022) to specify the `Option` key without also using `Command` or `Control`.
43 | */
44 | public struct Recorder: View { // swiftlint:disable:this type_name
45 | private let name: Name
46 | private let onChange: ((Shortcut?) -> Void)?
47 | private let hasLabel: Bool
48 | private let label: Label
49 |
50 | init(
51 | for name: Name,
52 | onChange: ((Shortcut?) -> Void)? = nil,
53 | hasLabel: Bool,
54 | @ViewBuilder label: () -> Label
55 | ) {
56 | self.name = name
57 | self.onChange = onChange
58 | self.hasLabel = hasLabel
59 | self.label = label()
60 | }
61 |
62 | public var body: some View {
63 | if hasLabel {
64 | if #available(macOS 13, *) {
65 | LabeledContent {
66 | _Recorder(
67 | name: name,
68 | onChange: onChange
69 | )
70 | } label: {
71 | label
72 | }
73 | } else {
74 | _Recorder(
75 | name: name,
76 | onChange: onChange
77 | )
78 | .formLabel {
79 | label
80 | }
81 | }
82 | } else {
83 | _Recorder(
84 | name: name,
85 | onChange: onChange
86 | )
87 | }
88 | }
89 | }
90 | }
91 |
92 | extension KeyboardShortcuts.Recorder {
93 | /**
94 | - Parameter name: Strongly-typed keyboard shortcut name.
95 | - 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.
96 | */
97 | public init(
98 | for name: KeyboardShortcuts.Name,
99 | onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil
100 | ) {
101 | self.init(
102 | for: name,
103 | onChange: onChange,
104 | hasLabel: false
105 | ) {}
106 | }
107 | }
108 |
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: LocalizedStringKey,
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 | extension KeyboardShortcuts.Recorder {
131 | /**
132 | - Parameter title: The title of the keyboard shortcut recorder, describing its purpose.
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 | */
136 | @_disfavoredOverload
137 | public init(
138 | _ title: String,
139 | name: KeyboardShortcuts.Name,
140 | onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil
141 | ) {
142 | self.init(
143 | for: name,
144 | onChange: onChange,
145 | hasLabel: true
146 | ) {
147 | Text(title)
148 | }
149 | }
150 | }
151 |
152 | extension KeyboardShortcuts.Recorder {
153 | /**
154 | - Parameter name: Strongly-typed keyboard shortcut name.
155 | - 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.
156 | - Parameter label: A view that describes the purpose of the keyboard shortcut recorder.
157 | */
158 | public init(
159 | for name: KeyboardShortcuts.Name,
160 | onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil,
161 | @ViewBuilder label: () -> Label
162 | ) {
163 | self.init(
164 | for: name,
165 | onChange: onChange,
166 | hasLabel: true,
167 | label: label
168 | )
169 | }
170 | }
171 |
172 | #Preview {
173 | KeyboardShortcuts.Recorder("record_shortcut", name: .init("xcodePreview"))
174 | .environment(\.locale, .init(identifier: "en"))
175 | }
176 |
177 | #Preview {
178 | KeyboardShortcuts.Recorder("record_shortcut", name: .init("xcodePreview"))
179 | .environment(\.locale, .init(identifier: "zh-Hans"))
180 | }
181 |
182 | #Preview {
183 | KeyboardShortcuts.Recorder("record_shortcut", name: .init("xcodePreview"))
184 | .environment(\.locale, .init(identifier: "ru"))
185 | }
186 | #endif
187 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/RecorderCocoa.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import AppKit
3 | import Carbon.HIToolbox
4 |
5 | extension KeyboardShortcuts {
6 | /**
7 | A `NSView` that lets the user record a keyboard shortcut.
8 |
9 | You would usually put this in your settings window.
10 |
11 | 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.
12 |
13 | It takes care of storing the keyboard shortcut in `UserDefaults` for you.
14 |
15 | ```swift
16 | import AppKit
17 | import KeyboardShortcuts
18 |
19 | final class SettingsViewController: NSViewController {
20 | override func loadView() {
21 | view = NSView()
22 |
23 | let recorder = KeyboardShortcuts.RecorderCocoa(for: .toggleUnicornMode)
24 | view.addSubview(recorder)
25 | }
26 | }
27 | ```
28 | */
29 | public final class RecorderCocoa: NSSearchField, NSSearchFieldDelegate {
30 | private let minimumWidth = 130.0
31 | private let onChange: ((_ shortcut: Shortcut?) -> Void)?
32 | private var canBecomeKey = false
33 | private var eventMonitor: LocalEventMonitor?
34 | private var shortcutsNameChangeObserver: NSObjectProtocol?
35 | private var windowDidResignKeyObserver: NSObjectProtocol?
36 | private var windowDidBecomeKeyObserver: NSObjectProtocol?
37 |
38 | /**
39 | The shortcut name for the recorder.
40 |
41 | Can be dynamically changed at any time.
42 | */
43 | public var shortcutName: Name {
44 | didSet {
45 | guard shortcutName != oldValue else {
46 | return
47 | }
48 |
49 | setStringValue(name: shortcutName)
50 |
51 | // This doesn't seem to be needed anymore, but I cannot test on older OS versions, so keeping it just in case.
52 | if #unavailable(macOS 12) {
53 | DispatchQueue.main.async { [self] in
54 | // Prevents the placeholder from being cut off.
55 | blur()
56 | }
57 | }
58 | }
59 | }
60 |
61 | /// :nodoc:
62 | override public var canBecomeKeyView: Bool { canBecomeKey }
63 |
64 | /// :nodoc:
65 | override public var intrinsicContentSize: CGSize {
66 | var size = super.intrinsicContentSize
67 | size.width = minimumWidth
68 | return size
69 | }
70 |
71 | private var cancelButton: NSButtonCell?
72 |
73 | private var showsCancelButton: Bool {
74 | get { (cell as? NSSearchFieldCell)?.cancelButtonCell != nil }
75 | set {
76 | (cell as? NSSearchFieldCell)?.cancelButtonCell = newValue ? cancelButton : nil
77 | }
78 | }
79 |
80 | /**
81 | - Parameter name: Strongly-typed keyboard shortcut name.
82 | - 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.
83 | */
84 | public required init(
85 | for name: Name,
86 | onChange: ((_ shortcut: Shortcut?) -> Void)? = nil
87 | ) {
88 | self.shortcutName = name
89 | self.onChange = onChange
90 |
91 | super.init(frame: .zero)
92 | self.delegate = self
93 | self.placeholderString = "record_shortcut".localized
94 | self.alignment = .center
95 | (cell as? NSSearchFieldCell)?.searchButtonCell = nil
96 |
97 | self.wantsLayer = true
98 | setContentHuggingPriority(.defaultHigh, for: .vertical)
99 | setContentHuggingPriority(.defaultHigh, for: .horizontal)
100 |
101 | // Hide the cancel button when not showing the shortcut so the placeholder text is properly centered. Must be last.
102 | self.cancelButton = (cell as? NSSearchFieldCell)?.cancelButtonCell
103 |
104 | setStringValue(name: name)
105 |
106 | setUpEvents()
107 | }
108 |
109 | @available(*, unavailable)
110 | public required init?(coder: NSCoder) {
111 | fatalError("init(coder:) has not been implemented")
112 | }
113 |
114 | private func setStringValue(name: KeyboardShortcuts.Name) {
115 | stringValue = getShortcut(for: shortcutName).map { "\($0)" } ?? ""
116 |
117 | // If `stringValue` is empty, hide the cancel button to let the placeholder center.
118 | showsCancelButton = !stringValue.isEmpty
119 | }
120 |
121 | private func setUpEvents() {
122 | shortcutsNameChangeObserver = NotificationCenter.default.addObserver(forName: .shortcutByNameDidChange, object: nil, queue: nil) { [weak self] notification in
123 | guard
124 | let self,
125 | let nameInNotification = notification.userInfo?["name"] as? KeyboardShortcuts.Name,
126 | nameInNotification == shortcutName
127 | else {
128 | return
129 | }
130 |
131 | setStringValue(name: nameInNotification)
132 | }
133 | }
134 |
135 | private func endRecording() {
136 | eventMonitor = nil
137 | placeholderString = "record_shortcut".localized
138 | showsCancelButton = !stringValue.isEmpty
139 | restoreCaret()
140 | KeyboardShortcuts.isPaused = false
141 | NotificationCenter.default.post(name: .recorderActiveStatusDidChange, object: nil, userInfo: ["isActive": false])
142 | }
143 |
144 | private func preventBecomingKey() {
145 | canBecomeKey = false
146 |
147 | // Prevent the control from receiving the initial focus.
148 | DispatchQueue.main.async { [self] in
149 | canBecomeKey = true
150 | }
151 | }
152 |
153 | /// :nodoc:
154 | public func controlTextDidChange(_ object: Notification) {
155 | if stringValue.isEmpty {
156 | saveShortcut(nil)
157 | }
158 |
159 | showsCancelButton = !stringValue.isEmpty
160 |
161 | if stringValue.isEmpty {
162 | // Hack to ensure that the placeholder centers after the above `showsCancelButton` setter.
163 | focus()
164 | }
165 | }
166 |
167 | /// :nodoc:
168 | public func controlTextDidEndEditing(_ object: Notification) {
169 | endRecording()
170 | }
171 |
172 | /// :nodoc:
173 | override public func viewDidMoveToWindow() {
174 | guard let window else {
175 | windowDidResignKeyObserver = nil
176 | windowDidBecomeKeyObserver = nil
177 | endRecording()
178 | return
179 | }
180 |
181 | // Ensures the recorder stops when the window is hidden.
182 | // This is especially important for Settings windows, which as of macOS 13.5, only hides instead of closes when you click the close button.
183 | windowDidResignKeyObserver = NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification, object: window, queue: nil) { [weak self] _ in
184 | guard
185 | let self,
186 | let window = self.window
187 | else {
188 | return
189 | }
190 |
191 | endRecording()
192 | window.makeFirstResponder(nil)
193 | }
194 |
195 | // Ensures the recorder does not receive initial focus when a hidden window becomes unhidden.
196 | windowDidBecomeKeyObserver = NotificationCenter.default.addObserver(forName: NSWindow.didBecomeKeyNotification, object: window, queue: nil) { [weak self] _ in
197 | self?.preventBecomingKey()
198 | }
199 |
200 | preventBecomingKey()
201 | }
202 |
203 | /// :nodoc:
204 | override public func becomeFirstResponder() -> Bool {
205 | let shouldBecomeFirstResponder = super.becomeFirstResponder()
206 |
207 | guard shouldBecomeFirstResponder else {
208 | return shouldBecomeFirstResponder
209 | }
210 |
211 | placeholderString = "press_shortcut".localized
212 | showsCancelButton = !stringValue.isEmpty
213 | hideCaret()
214 | KeyboardShortcuts.isPaused = true // The position here matters.
215 | NotificationCenter.default.post(name: .recorderActiveStatusDidChange, object: nil, userInfo: ["isActive": true])
216 |
217 | eventMonitor = LocalEventMonitor(events: [.keyDown, .leftMouseUp, .rightMouseUp]) { [weak self] event in
218 | guard let self else {
219 | return nil
220 | }
221 |
222 | let clickPoint = convert(event.locationInWindow, from: nil)
223 | let clickMargin = 3.0
224 |
225 | if
226 | event.type == .leftMouseUp || event.type == .rightMouseUp,
227 | !bounds.insetBy(dx: -clickMargin, dy: -clickMargin).contains(clickPoint)
228 | {
229 | blur()
230 | return event
231 | }
232 |
233 | guard event.isKeyEvent else {
234 | return nil
235 | }
236 |
237 | if
238 | event.modifiers.isEmpty,
239 | event.specialKey == .tab
240 | {
241 | blur()
242 |
243 | // We intentionally bubble up the event so it can focus the next responder.
244 | return event
245 | }
246 |
247 | if
248 | event.modifiers.isEmpty,
249 | event.keyCode == kVK_Escape // TODO: Make this strongly typed.
250 | {
251 | blur()
252 | return nil
253 | }
254 |
255 | if
256 | event.modifiers.isEmpty,
257 | event.specialKey == .delete
258 | || event.specialKey == .deleteForward
259 | || event.specialKey == .backspace
260 | {
261 | clear()
262 | return nil
263 | }
264 |
265 | // The “shift” key is not allowed without other modifiers or a function key, since it doesn't actually work.
266 | guard
267 | !event.modifiers.subtracting([.shift, .function]).isEmpty
268 | || event.specialKey?.isFunctionKey == true,
269 | let shortcut = Shortcut(event: event)
270 | else {
271 | NSSound.beep()
272 | return nil
273 | }
274 |
275 | if let menuItem = shortcut.takenByMainMenu {
276 | // 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?
277 | blur()
278 |
279 | NSAlert.showModal(
280 | for: window,
281 | title: String.localizedStringWithFormat("keyboard_shortcut_used_by_menu_item".localized, menuItem.title)
282 | )
283 |
284 | focus()
285 |
286 | return nil
287 | }
288 |
289 | // See: https://developer.apple.com/forums/thread/763878?answerId=804374022#804374022
290 | if shortcut.isDisallowed {
291 | blur()
292 |
293 | NSAlert.showModal(
294 | for: window,
295 | title: "keyboard_shortcut_disallowed".localized
296 | )
297 |
298 | focus()
299 | return nil
300 | }
301 |
302 | if shortcut.isTakenBySystem {
303 | blur()
304 |
305 | let modalResponse = NSAlert.showModal(
306 | for: window,
307 | title: "keyboard_shortcut_used_by_system".localized,
308 | // TODO: Add button to offer to open the relevant system settings pane for the user.
309 | message: "keyboard_shortcuts_can_be_changed".localized,
310 | buttonTitles: [
311 | "ok".localized,
312 | "force_use_shortcut".localized
313 | ]
314 | )
315 |
316 | focus()
317 |
318 | // If the user has selected "Use Anyway" in the dialog (the second option), we'll continue setting the keyboard shorcut even though it's reserved by the system.
319 | guard modalResponse == .alertSecondButtonReturn else {
320 | return nil
321 | }
322 | }
323 |
324 | stringValue = "\(shortcut)"
325 | showsCancelButton = true
326 |
327 | saveShortcut(shortcut)
328 | blur()
329 |
330 | return nil
331 | }.start()
332 |
333 | return shouldBecomeFirstResponder
334 | }
335 |
336 | private func saveShortcut(_ shortcut: Shortcut?) {
337 | setShortcut(shortcut, for: shortcutName)
338 | onChange?(shortcut)
339 | }
340 | }
341 | }
342 |
343 | extension Notification.Name {
344 | static let recorderActiveStatusDidChange = Self("KeyboardShortcuts_recorderActiveStatusDidChange")
345 | }
346 | #endif
347 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Shortcut.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import AppKit
3 | import Carbon.HIToolbox
4 | import SwiftUI
5 |
6 | extension KeyboardShortcuts {
7 | /**
8 | A keyboard shortcut.
9 | */
10 | public struct Shortcut: Hashable, Codable, Sendable {
11 | /**
12 | Carbon modifiers are not always stored as the same number.
13 |
14 | For example, the system has `⌃F2` stored with the modifiers number `135168`, but if you press the keyboard shortcut, you get `4096`.
15 | */
16 | private static func normalizeModifiers(_ carbonModifiers: Int) -> Int {
17 | NSEvent.ModifierFlags(carbon: carbonModifiers).carbon
18 | }
19 |
20 | /**
21 | The keyboard key of the shortcut.
22 | */
23 | public var key: Key? { Key(rawValue: carbonKeyCode) }
24 |
25 | /**
26 | The modifier keys of the shortcut.
27 | */
28 | public var modifiers: NSEvent.ModifierFlags { NSEvent.ModifierFlags(carbon: carbonModifiers) }
29 |
30 | /**
31 | Low-level represetation of the key.
32 |
33 | You most likely don't need this.
34 | */
35 | public let carbonKeyCode: Int
36 |
37 | /**
38 | Low-level representation of the modifier keys.
39 |
40 | You most likely don't need this.
41 | */
42 | public let carbonModifiers: Int
43 |
44 | /**
45 | Initialize from a strongly-typed key and modifiers.
46 | */
47 | public init(_ key: Key, modifiers: NSEvent.ModifierFlags = []) {
48 | self.init(
49 | carbonKeyCode: key.rawValue,
50 | carbonModifiers: modifiers.carbon
51 | )
52 | }
53 |
54 | /**
55 | Initialize from a key event.
56 | */
57 | public init?(event: NSEvent) {
58 | guard event.isKeyEvent else {
59 | return nil
60 | }
61 |
62 | self.init(
63 | carbonKeyCode: Int(event.keyCode),
64 | // Note: We could potentially support users specifying shortcuts with the Fn key, but I haven't found a reliable way to differentate when to display the Fn key and not. For example, with Fn+F1 we only want to display F1, but with Fn+V, we want to display both. I cannot just specialize it for F keys as it applies to other keys too, like Fn+arrowup.
65 | carbonModifiers: event.modifierFlags.subtracting(.function).carbon
66 | )
67 | }
68 |
69 | /**
70 | Initialize from a keyboard shortcut stored by `Recorder` or `RecorderCocoa`.
71 | */
72 | public init?(name: Name) {
73 | guard let shortcut = getShortcut(for: name) else {
74 | return nil
75 | }
76 |
77 | self = shortcut
78 | }
79 |
80 | /**
81 | Initialize from a key code number and modifier code.
82 |
83 | You most likely don't need this.
84 | */
85 | public init(carbonKeyCode: Int, carbonModifiers: Int = 0) {
86 | self.carbonKeyCode = carbonKeyCode
87 | self.carbonModifiers = Self.normalizeModifiers(carbonModifiers)
88 | }
89 | }
90 | }
91 |
92 | enum Constants {
93 | static let isSandboxed = ProcessInfo.processInfo.environment.hasKey("APP_SANDBOX_CONTAINER_ID")
94 | }
95 |
96 | extension KeyboardShortcuts.Shortcut {
97 | /**
98 | System-defined keyboard shortcuts.
99 | */
100 | static var system: [Self] {
101 | CarbonKeyboardShortcuts.system
102 | }
103 |
104 | /**
105 | Check whether the keyboard shortcut is disallowed.
106 | */
107 | var isDisallowed: Bool {
108 | let osVersion = ProcessInfo.processInfo.operatingSystemVersion
109 |
110 | guard
111 | osVersion.majorVersion == 15,
112 | osVersion.minorVersion == 0 || osVersion.minorVersion == 1,
113 | Constants.isSandboxed
114 | else {
115 | return false
116 | }
117 |
118 | if !modifiers.contains(.option) {
119 | return false // Allowed if Option is not involved
120 | }
121 |
122 | // If Option is present, ensure there's at least one modifier other than Option and Shift
123 | let otherModifiers: NSEvent.ModifierFlags = [.command, .control, .function, .capsLock]
124 | return modifiers.isDisjoint(with: otherModifiers)
125 | }
126 |
127 | /**
128 | Check whether the keyboard shortcut is already taken by the system.
129 | */
130 | var isTakenBySystem: Bool {
131 | guard self != Self(.f12, modifiers: []) else {
132 | return false
133 | }
134 |
135 | return Self.system.contains(self)
136 | }
137 | }
138 |
139 | extension KeyboardShortcuts.Shortcut {
140 | /**
141 | Recursively finds a menu item in the given menu that has a matching key equivalent and modifier.
142 | */
143 | @MainActor
144 | func menuItemWithMatchingShortcut(in menu: NSMenu) -> NSMenuItem? {
145 | for item in menu.items {
146 | var keyEquivalent = item.keyEquivalent
147 | var keyEquivalentModifierMask = item.keyEquivalentModifierMask
148 |
149 | if modifiers.contains(.shift), keyEquivalent.lowercased() != keyEquivalent {
150 | keyEquivalent = keyEquivalent.lowercased()
151 | keyEquivalentModifierMask.insert(.shift)
152 | }
153 |
154 | if
155 | self.nsMenuItemKeyEquivalent == keyEquivalent, // Note `nil != ""`
156 | self.modifiers == keyEquivalentModifierMask
157 | {
158 | return item
159 | }
160 |
161 | if
162 | let submenu = item.submenu,
163 | let menuItem = menuItemWithMatchingShortcut(in: submenu)
164 | {
165 | return menuItem
166 | }
167 | }
168 |
169 | return nil
170 | }
171 |
172 | /**
173 | Returns a menu item in the app's main menu that has a matching key equivalent and modifier.
174 | */
175 | @MainActor
176 | var takenByMainMenu: NSMenuItem? {
177 | guard let mainMenu = NSApp.mainMenu else {
178 | return nil
179 | }
180 |
181 | return menuItemWithMatchingShortcut(in: mainMenu)
182 | }
183 | }
184 |
185 | /*
186 | An enumeration of special keys requiring specific handling when used with `RecorderCocoa`, AppKit’s `NSMenuItem`, and SwiftUI’s `.keyboardShortcut(_:modifiers:)`.
187 |
188 | Using an enumeration ensures all cases are exhaustively addressed in all three contexts, providing compile-time safety and reducing the risk of unhandled keys.
189 | */
190 | private enum SpecialKey {
191 | case `return`
192 | case delete
193 | case deleteForward
194 | case end
195 | case escape
196 | case help
197 | case home
198 | case space
199 | case tab
200 | case pageUp
201 | case pageDown
202 | case upArrow
203 | case rightArrow
204 | case downArrow
205 | case leftArrow
206 | case f1
207 | case f2
208 | case f3
209 | case f4
210 | case f5
211 | case f6
212 | case f7
213 | case f8
214 | case f9
215 | case f10
216 | case f11
217 | case f12
218 | case f13
219 | case f14
220 | case f15
221 | case f16
222 | case f17
223 | case f18
224 | case f19
225 | case f20
226 | case keypad0
227 | case keypad1
228 | case keypad2
229 | case keypad3
230 | case keypad4
231 | case keypad5
232 | case keypad6
233 | case keypad7
234 | case keypad8
235 | case keypad9
236 | case keypadClear
237 | case keypadDecimal
238 | case keypadDivide
239 | case keypadEnter
240 | case keypadEquals
241 | case keypadMinus
242 | case keypadMultiply
243 | case keypadPlus
244 | }
245 |
246 | private let keyToSpecialKeyMapping: [KeyboardShortcuts.Key: SpecialKey] = [
247 | .return: .return,
248 | .delete: .delete,
249 | .deleteForward: .deleteForward,
250 | .end: .end,
251 | .escape: .escape,
252 | .help: .help,
253 | .home: .home,
254 | .space: .space,
255 | .tab: .tab,
256 | .pageUp: .pageUp,
257 | .pageDown: .pageDown,
258 | .upArrow: .upArrow,
259 | .rightArrow: .rightArrow,
260 | .downArrow: .downArrow,
261 | .leftArrow: .leftArrow,
262 | .f1: .f1,
263 | .f2: .f2,
264 | .f3: .f3,
265 | .f4: .f4,
266 | .f5: .f5,
267 | .f6: .f6,
268 | .f7: .f7,
269 | .f8: .f8,
270 | .f9: .f9,
271 | .f10: .f10,
272 | .f11: .f11,
273 | .f12: .f12,
274 | .f13: .f13,
275 | .f14: .f14,
276 | .f15: .f15,
277 | .f16: .f16,
278 | .f17: .f17,
279 | .f18: .f18,
280 | .f19: .f19,
281 | .f20: .f20,
282 | .keypad0: .keypad0,
283 | .keypad1: .keypad1,
284 | .keypad2: .keypad2,
285 | .keypad3: .keypad3,
286 | .keypad4: .keypad4,
287 | .keypad5: .keypad5,
288 | .keypad6: .keypad6,
289 | .keypad7: .keypad7,
290 | .keypad8: .keypad8,
291 | .keypad9: .keypad9,
292 | .keypadClear: .keypadClear,
293 | .keypadDecimal: .keypadDecimal,
294 | .keypadDivide: .keypadDivide,
295 | .keypadEnter: .keypadEnter,
296 | .keypadEquals: .keypadEquals,
297 | .keypadMinus: .keypadMinus,
298 | .keypadMultiply: .keypadMultiply,
299 | .keypadPlus: .keypadPlus
300 | ]
301 |
302 | extension SpecialKey {
303 | fileprivate var presentableDescription: String {
304 | switch self {
305 | case .return:
306 | "↩"
307 | case .delete:
308 | "⌫"
309 | case .deleteForward:
310 | "⌦"
311 | case .end:
312 | "↘"
313 | case .escape:
314 | "⎋"
315 | case .help:
316 | "?⃝"
317 | case .home:
318 | "↖"
319 | case .space:
320 | "space_key".localized.capitalized // This matches what macOS uses.
321 | case .tab:
322 | "⇥"
323 | case .pageUp:
324 | "⇞"
325 | case .pageDown:
326 | "⇟"
327 | case .upArrow:
328 | "↑"
329 | case .rightArrow:
330 | "→"
331 | case .downArrow:
332 | "↓"
333 | case .leftArrow:
334 | "←"
335 | case .f1:
336 | "F1"
337 | case .f2:
338 | "F2"
339 | case .f3:
340 | "F3"
341 | case .f4:
342 | "F4"
343 | case .f5:
344 | "F5"
345 | case .f6:
346 | "F6"
347 | case .f7:
348 | "F7"
349 | case .f8:
350 | "F8"
351 | case .f9:
352 | "F9"
353 | case .f10:
354 | "F10"
355 | case .f11:
356 | "F11"
357 | case .f12:
358 | "F12"
359 | case .f13:
360 | "F13"
361 | case .f14:
362 | "F14"
363 | case .f15:
364 | "F15"
365 | case .f16:
366 | "F16"
367 | case .f17:
368 | "F17"
369 | case .f18:
370 | "F18"
371 | case .f19:
372 | "F19"
373 | case .f20:
374 | "F20"
375 |
376 | // Representations for numeric keypad keys with ⃣ Unicode U+20e3 'COMBINING ENCLOSING KEYCAP'
377 | case .keypad0:
378 | "0\u{20e3}"
379 | case .keypad1:
380 | "1\u{20e3}"
381 | case .keypad2:
382 | "2\u{20e3}"
383 | case .keypad3:
384 | "3\u{20e3}"
385 | case .keypad4:
386 | "4\u{20e3}"
387 | case .keypad5:
388 | "5\u{20e3}"
389 | case .keypad6:
390 | "6\u{20e3}"
391 | case .keypad7:
392 | "7\u{20e3}"
393 | case .keypad8:
394 | "8\u{20e3}"
395 | case .keypad9:
396 | "9\u{20e3}"
397 | // There's "⌧“ 'X In A Rectangle Box' (U+2327), "☒" 'Ballot Box with X' (U+2612), "×" 'Multiplication Sign' (U+00d7), "⨯" 'Vector or Cross Product' (U+2a2f), or a plain small x. All combined symbols appear bigger.
398 | case .keypadClear:
399 | "☒\u{20e3}" // The combined symbol appears bigger than the other combined 'keycaps'
400 | // TODO: Respect locale decimal separator ("." or ",")
401 | case .keypadDecimal:
402 | ".\u{20e3}"
403 | case .keypadDivide:
404 | "/\u{20e3}"
405 | // "⏎" 'Return Symbol' (U+23CE) but "↩" 'Leftwards Arrow with Hook' (U+00d7) seems to be more common on macOS.
406 | case .keypadEnter:
407 | "↩\u{20e3}" // The combined symbol appears bigger than the other combined 'keycaps'
408 | case .keypadEquals:
409 | "=\u{20e3}"
410 | case .keypadMinus:
411 | "-\u{20e3}"
412 | case .keypadMultiply:
413 | "*\u{20e3}"
414 | case .keypadPlus:
415 | "+\u{20e3}"
416 | }
417 | }
418 |
419 | @available(macOS 11.0, *)
420 | fileprivate var swiftUIKeyEquivalent: SwiftUI.KeyEquivalent? {
421 | switch self {
422 | case .return:
423 | .return
424 | case .delete:
425 | .delete
426 | case .deleteForward:
427 | .deleteForward
428 | case .end:
429 | .end
430 | case .escape:
431 | .escape
432 | case .help:
433 | KeyEquivalent(unicodeScalarValue: NSHelpFunctionKey)
434 | case .home:
435 | .home
436 | case .space:
437 | .space
438 | case .tab:
439 | .tab
440 | case .pageUp:
441 | .pageUp
442 | case .pageDown:
443 | .pageDown
444 | case .upArrow:
445 | .upArrow
446 | case .rightArrow:
447 | .rightArrow
448 | case .downArrow:
449 | .downArrow
450 | case .leftArrow:
451 | .leftArrow
452 | case .f1:
453 | KeyEquivalent(unicodeScalarValue: NSF1FunctionKey)
454 | case .f2:
455 | KeyEquivalent(unicodeScalarValue: NSF2FunctionKey)
456 | case .f3:
457 | KeyEquivalent(unicodeScalarValue: NSF3FunctionKey)
458 | case .f4:
459 | KeyEquivalent(unicodeScalarValue: NSF4FunctionKey)
460 | case .f5:
461 | KeyEquivalent(unicodeScalarValue: NSF5FunctionKey)
462 | case .f6:
463 | KeyEquivalent(unicodeScalarValue: NSF6FunctionKey)
464 | case .f7:
465 | KeyEquivalent(unicodeScalarValue: NSF7FunctionKey)
466 | case .f8:
467 | KeyEquivalent(unicodeScalarValue: NSF8FunctionKey)
468 | case .f9:
469 | KeyEquivalent(unicodeScalarValue: NSF9FunctionKey)
470 | case .f10:
471 | KeyEquivalent(unicodeScalarValue: NSF10FunctionKey)
472 | case .f11:
473 | KeyEquivalent(unicodeScalarValue: NSF11FunctionKey)
474 | case .f12:
475 | KeyEquivalent(unicodeScalarValue: NSF12FunctionKey)
476 | case .f13:
477 | KeyEquivalent(unicodeScalarValue: NSF13FunctionKey)
478 | case .f14:
479 | KeyEquivalent(unicodeScalarValue: NSF14FunctionKey)
480 | case .f15:
481 | KeyEquivalent(unicodeScalarValue: NSF15FunctionKey)
482 | case .f16:
483 | KeyEquivalent(unicodeScalarValue: NSF16FunctionKey)
484 | case .f17:
485 | KeyEquivalent(unicodeScalarValue: NSF17FunctionKey)
486 | case .f18:
487 | KeyEquivalent(unicodeScalarValue: NSF18FunctionKey)
488 | case .f19:
489 | KeyEquivalent(unicodeScalarValue: NSF19FunctionKey)
490 | case .f20:
491 | KeyEquivalent(unicodeScalarValue: NSF20FunctionKey)
492 | // Neither the " ⃣" enclosed characters (e.g. "7⃣") nor regular
493 | // characters with the `.numpad` modifier produce `SwiftUI` buttons that
494 | // will capture the only the number pad's keys (last checked: MacOS 14).
495 | // Return `nil` to prevent definition of incorrect shortcuts.
496 | case .keypad0:
497 | nil
498 | case .keypad1:
499 | nil
500 | case .keypad2:
501 | nil
502 | case .keypad3:
503 | nil
504 | case .keypad4:
505 | nil
506 | case .keypad5:
507 | nil
508 | case .keypad6:
509 | nil
510 | case .keypad7:
511 | nil
512 | case .keypad8:
513 | nil
514 | case .keypad9:
515 | nil
516 | case .keypadClear:
517 | nil
518 | case .keypadDecimal:
519 | nil
520 | case .keypadDivide:
521 | nil
522 | case .keypadEnter:
523 | nil
524 | case .keypadEquals:
525 | nil
526 | case .keypadMinus:
527 | nil
528 | case .keypadMultiply:
529 | nil
530 | case .keypadPlus:
531 | nil
532 | }
533 | }
534 |
535 | fileprivate var appKitMenuItemKeyEquivalent: Character? {
536 | switch self {
537 | case .return:
538 | "↩"
539 | case .delete:
540 | "⌫"
541 | case .deleteForward:
542 | "⌦"
543 | case .end:
544 | "↘"
545 | case .escape:
546 | "⎋"
547 | case .help:
548 | "?⃝"
549 | case .home:
550 | "↖"
551 | case .space:
552 | "\u{0020}"
553 | case .tab:
554 | "⇥"
555 | case .pageUp:
556 | "⇞"
557 | case .pageDown:
558 | "⇟"
559 | case .upArrow:
560 | "↑"
561 | case .rightArrow:
562 | "→"
563 | case .downArrow:
564 | "↓"
565 | case .leftArrow:
566 | "←"
567 | case .f1:
568 | Character(unicodeScalarValue: NSF1FunctionKey)
569 | case .f2:
570 | Character(unicodeScalarValue: NSF2FunctionKey)
571 | case .f3:
572 | Character(unicodeScalarValue: NSF3FunctionKey)
573 | case .f4:
574 | Character(unicodeScalarValue: NSF4FunctionKey)
575 | case .f5:
576 | Character(unicodeScalarValue: NSF5FunctionKey)
577 | case .f6:
578 | Character(unicodeScalarValue: NSF6FunctionKey)
579 | case .f7:
580 | Character(unicodeScalarValue: NSF7FunctionKey)
581 | case .f8:
582 | Character(unicodeScalarValue: NSF8FunctionKey)
583 | case .f9:
584 | Character(unicodeScalarValue: NSF9FunctionKey)
585 | case .f10:
586 | Character(unicodeScalarValue: NSF10FunctionKey)
587 | case .f11:
588 | Character(unicodeScalarValue: NSF11FunctionKey)
589 | case .f12:
590 | Character(unicodeScalarValue: NSF12FunctionKey)
591 | case .f13:
592 | Character(unicodeScalarValue: NSF13FunctionKey)
593 | case .f14:
594 | Character(unicodeScalarValue: NSF14FunctionKey)
595 | case .f15:
596 | Character(unicodeScalarValue: NSF15FunctionKey)
597 | case .f16:
598 | Character(unicodeScalarValue: NSF16FunctionKey)
599 | case .f17:
600 | Character(unicodeScalarValue: NSF17FunctionKey)
601 | case .f18:
602 | Character(unicodeScalarValue: NSF18FunctionKey)
603 | case .f19:
604 | Character(unicodeScalarValue: NSF19FunctionKey)
605 | case .f20:
606 | Character(unicodeScalarValue: NSF20FunctionKey)
607 | // Neither the " ⃣" enclosed characters (e.g. "7⃣") nor regular
608 | // characters with the `.numericPad` modifier produce a `MenuItem` that
609 | // will capture the only the number pad's keys (last checked: MacOS 14).
610 | // Return `nil` to prevent definition of incorrect shortcuts.
611 | case .keypad0:
612 | nil
613 | case .keypad1:
614 | nil
615 | case .keypad2:
616 | nil
617 | case .keypad3:
618 | nil
619 | case .keypad4:
620 | nil
621 | case .keypad5:
622 | nil
623 | case .keypad6:
624 | nil
625 | case .keypad7:
626 | nil
627 | case .keypad8:
628 | nil
629 | case .keypad9:
630 | nil
631 | case .keypadClear:
632 | nil
633 | case .keypadDecimal:
634 | nil
635 | case .keypadDivide:
636 | nil
637 | case .keypadEnter:
638 | nil
639 | case .keypadEquals:
640 | nil
641 | case .keypadMinus:
642 | nil
643 | case .keypadMultiply:
644 | nil
645 | case .keypadPlus:
646 | nil
647 | }
648 | }
649 | }
650 |
651 | extension KeyboardShortcuts.Shortcut {
652 | @MainActor // `TISGetInputSourceProperty` crashes if called on a non-main thread.
653 | fileprivate func keyToCharacter() -> Character? {
654 | guard
655 | let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue(),
656 | let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData)
657 | else {
658 | return nil
659 | }
660 |
661 | guard key.flatMap({ keyToSpecialKeyMapping[$0] }) == nil else {
662 | assertionFailure("Special keys should get special treatment and should not be translated using keyToCharacter()")
663 | return nil
664 | }
665 |
666 | let layoutData = unsafeBitCast(layoutDataPointer, to: CFData.self)
667 | let keyLayout = unsafeBitCast(CFDataGetBytePtr(layoutData), to: UnsafePointer.self)
668 | var deadKeyState: UInt32 = 0
669 | let maxLength = 4
670 | var length = 0
671 | var characters = [UniChar](repeating: 0, count: maxLength)
672 |
673 | let error = CoreServices.UCKeyTranslate(
674 | keyLayout,
675 | UInt16(carbonKeyCode),
676 | UInt16(CoreServices.kUCKeyActionDisplay),
677 | 0, // No modifiers
678 | UInt32(LMGetKbdType()),
679 | OptionBits(CoreServices.kUCKeyTranslateNoDeadKeysBit),
680 | &deadKeyState,
681 | maxLength,
682 | &length,
683 | &characters
684 | )
685 |
686 | guard error == noErr else {
687 | return nil
688 | }
689 |
690 | let string = String(utf16CodeUnits: characters, count: length)
691 | if string.count == 1 {
692 | return string.first
693 | }
694 |
695 | return nil
696 | }
697 |
698 | /**
699 | Key equivalent string in `NSMenuItem` format.
700 |
701 | This can be used to show the keyboard shortcut in a `NSMenuItem` by assigning it to `NSMenuItem#keyEquivalent`.
702 |
703 | - Note: Don't forget to also pass ``Shortcut/modifiers`` to `NSMenuItem#keyEquivalentModifierMask`.
704 | */
705 | @MainActor
706 | public var nsMenuItemKeyEquivalent: String? {
707 | if
708 | let key,
709 | let specialKey = keyToSpecialKeyMapping[key]
710 | {
711 | if let keyEquivalent = specialKey.appKitMenuItemKeyEquivalent {
712 | return String(keyEquivalent)
713 | }
714 | } else if let character = keyToCharacter() {
715 | return String(character)
716 | }
717 |
718 | return nil
719 | }
720 | }
721 |
722 | extension KeyboardShortcuts.Shortcut: CustomStringConvertible {
723 | /**
724 | The string representation of the keyboard shortcut.
725 |
726 | ```swift
727 | print(KeyboardShortcuts.Shortcut(.a, modifiers: [.command]))
728 | //=> "⌘A"
729 | ```
730 | */
731 |
732 | @MainActor
733 | var presentableDescription: String {
734 | if
735 | let key,
736 | let specialKey = keyToSpecialKeyMapping[key]
737 | {
738 | return modifiers.presentableDescription + specialKey.presentableDescription
739 | }
740 |
741 | return modifiers.presentableDescription + String(keyToCharacter() ?? "�").capitalized
742 | }
743 |
744 | @MainActor
745 | public var description: String {
746 | // TODO: `description` needs to be `nonisolated`
747 | presentableDescription
748 | }
749 | }
750 |
751 | extension KeyboardShortcuts.Shortcut {
752 | @available(macOS 11, *)
753 | @MainActor
754 | var toSwiftUI: KeyboardShortcut? {
755 | if
756 | let key,
757 | let specialKey = keyToSpecialKeyMapping[key]
758 | {
759 | if let keyEquivalent = specialKey.swiftUIKeyEquivalent {
760 | if #available(macOS 12.0, *) {
761 | return KeyboardShortcut(keyEquivalent, modifiers: modifiers.toEventModifiers, localization: .custom)
762 | } else {
763 | return KeyboardShortcut(keyEquivalent, modifiers: modifiers.toEventModifiers)
764 | }
765 | }
766 | } else if let character = keyToCharacter() {
767 | if #available(macOS 12.0, *) {
768 | return KeyboardShortcut(KeyEquivalent(character), modifiers: modifiers.toEventModifiers, localization: .custom)
769 | } else {
770 | return KeyboardShortcut(KeyEquivalent(character), modifiers: modifiers.toEventModifiers)
771 | }
772 | }
773 |
774 | return nil
775 | }
776 | }
777 | #endif
778 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/Utilities.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | #if os(macOS)
4 | import Carbon.HIToolbox
5 |
6 |
7 | extension String {
8 | /**
9 | Makes the string localizable.
10 | */
11 | var localized: String {
12 | NSLocalizedString(self, bundle: .module, comment: self)
13 | }
14 | }
15 |
16 |
17 | extension Data {
18 | var toString: String? { String(data: self, encoding: .utf8) }
19 | }
20 |
21 |
22 | extension NSEvent {
23 | var isKeyEvent: Bool { type == .keyDown || type == .keyUp }
24 | }
25 |
26 |
27 | extension NSTextField {
28 | func hideCaret() {
29 | (currentEditor() as? NSTextView)?.insertionPointColor = .clear
30 | }
31 |
32 | func restoreCaret() {
33 | (currentEditor() as? NSTextView)?.insertionPointColor = .labelColor
34 | }
35 | }
36 |
37 |
38 | extension NSView {
39 | func focus() {
40 | window?.makeFirstResponder(self)
41 | }
42 |
43 | func blur() {
44 | window?.makeFirstResponder(nil)
45 | }
46 | }
47 |
48 |
49 | /**
50 | Listen to local events.
51 |
52 | - Important: Don't foret to call `.start()`.
53 |
54 | ```
55 | eventMonitor = LocalEventMonitor(events: [.leftMouseDown, .rightMouseDown]) { event in
56 | // Do something
57 |
58 | return event
59 | }.start()
60 | ```
61 | */
62 | final class LocalEventMonitor {
63 | private let events: NSEvent.EventTypeMask
64 | private let callback: (NSEvent) -> NSEvent?
65 | private weak var monitor: AnyObject?
66 |
67 | init(events: NSEvent.EventTypeMask, callback: @escaping (NSEvent) -> NSEvent?) {
68 | self.events = events
69 | self.callback = callback
70 | }
71 |
72 | deinit {
73 | stop()
74 | }
75 |
76 | @discardableResult
77 | func start() -> Self {
78 | monitor = NSEvent.addLocalMonitorForEvents(matching: events, handler: callback) as AnyObject
79 | return self
80 | }
81 |
82 | func stop() {
83 | guard let monitor else {
84 | return
85 | }
86 |
87 | NSEvent.removeMonitor(monitor)
88 | }
89 | }
90 |
91 |
92 | final class RunLoopLocalEventMonitor {
93 | private let runLoopMode: RunLoop.Mode
94 | private let callback: (NSEvent) -> NSEvent?
95 | private let observer: CFRunLoopObserver
96 |
97 | init(
98 | events: NSEvent.EventTypeMask,
99 | runLoopMode: RunLoop.Mode,
100 | callback: @escaping (NSEvent) -> NSEvent?
101 | ) {
102 | self.runLoopMode = runLoopMode
103 | self.callback = callback
104 |
105 | self.observer = CFRunLoopObserverCreateWithHandler(nil, CFRunLoopActivity.beforeSources.rawValue, true, 0) { _, _ in
106 | // Pull all events from the queue and handle the ones matching the given types.
107 | // Non-matching events are left untouched, maintaining their order in the queue.
108 |
109 | var eventsToHandle = [NSEvent]()
110 |
111 | // Retrieve all events from the event queue to preserve their order (instead of using the `matching` parameter).
112 | while let eventToHandle = NSApp.nextEvent(matching: .any, until: nil, inMode: .default, dequeue: true) {
113 | eventsToHandle.append(eventToHandle)
114 | }
115 |
116 | // Iterate over the gathered events, instead of doing it directly in the `while` loop, to avoid potential infinite loops caused by re-retrieving undiscarded events.
117 | for eventToHandle in eventsToHandle {
118 | var handledEvent: NSEvent?
119 |
120 | if !events.contains(NSEvent.EventTypeMask(rawValue: 1 << eventToHandle.type.rawValue)) {
121 | handledEvent = eventToHandle
122 | } else if let callbackEvent = callback(eventToHandle) {
123 | handledEvent = callbackEvent
124 | }
125 |
126 | guard let handledEvent else {
127 | continue
128 | }
129 |
130 | NSApp.postEvent(handledEvent, atStart: false)
131 | }
132 | }
133 | }
134 |
135 | deinit {
136 | stop()
137 | }
138 |
139 | @discardableResult
140 | func start() -> Self {
141 | CFRunLoopAddObserver(RunLoop.current.getCFRunLoop(), observer, CFRunLoopMode(runLoopMode.rawValue as CFString))
142 | return self
143 | }
144 |
145 | func stop() {
146 | CFRunLoopRemoveObserver(RunLoop.current.getCFRunLoop(), observer, CFRunLoopMode(runLoopMode.rawValue as CFString))
147 | }
148 | }
149 |
150 |
151 | extension NSEvent {
152 | static var modifiers: ModifierFlags {
153 | modifierFlags
154 | .intersection(.deviceIndependentFlagsMask)
155 | // We remove `capsLock` as it shouldn't affect the modifiers.
156 | // We remove `numericPad` as arrow keys trigger it, use `event.specialKeys` instead.
157 | .subtracting([.capsLock, .numericPad])
158 | }
159 |
160 | /**
161 | Real modifiers.
162 |
163 | - Note: Prefer this over `.modifierFlags`.
164 |
165 | ```
166 | // Check if Command is one of possible more modifiers keys
167 | event.modifiers.contains(.command)
168 |
169 | // Check if Command is the only modifier key
170 | event.modifiers == .command
171 |
172 | // Check if Command and Shift are the only modifiers
173 | event.modifiers == [.command, .shift]
174 | ```
175 | */
176 | var modifiers: ModifierFlags {
177 | modifierFlags
178 | .intersection(.deviceIndependentFlagsMask)
179 | // We remove `capsLock` as it shouldn't affect the modifiers.
180 | // We remove `numericPad` as arrow keys trigger it, use `event.specialKeys` instead.
181 | .subtracting([.capsLock, .numericPad])
182 | }
183 | }
184 |
185 |
186 | extension NSSearchField {
187 | /**
188 | Clear the search field.
189 | */
190 | func clear() {
191 | (cell as? NSSearchFieldCell)?.cancelButtonCell?.performClick(self)
192 | }
193 | }
194 |
195 |
196 | extension NSAlert {
197 | /**
198 | Show an alert as a window-modal sheet, or as an app-modal (window-independent) alert if the window is `nil` or not given.
199 | */
200 | @discardableResult
201 | static func showModal(
202 | for window: NSWindow? = nil,
203 | title: String,
204 | message: String? = nil,
205 | style: Style = .warning,
206 | icon: NSImage? = nil,
207 | buttonTitles: [String] = []
208 | ) -> NSApplication.ModalResponse {
209 | NSAlert(
210 | title: title,
211 | message: message,
212 | style: style,
213 | icon: icon,
214 | buttonTitles: buttonTitles
215 | ).runModal(for: window)
216 | }
217 |
218 | convenience init(
219 | title: String,
220 | message: String? = nil,
221 | style: Style = .warning,
222 | icon: NSImage? = nil,
223 | buttonTitles: [String] = []
224 | ) {
225 | self.init()
226 | self.messageText = title
227 | self.alertStyle = style
228 | self.icon = icon
229 |
230 | for buttonTitle in buttonTitles {
231 | addButton(withTitle: buttonTitle)
232 | }
233 |
234 | if let message {
235 | self.informativeText = message
236 | }
237 | }
238 |
239 | /**
240 | Runs the alert as a window-modal sheet, or as an app-modal (window-independent) alert if the window is `nil` or not given.
241 | */
242 | @discardableResult
243 | func runModal(for window: NSWindow? = nil) -> NSApplication.ModalResponse {
244 | guard let window else {
245 | return runModal()
246 | }
247 |
248 | beginSheetModal(for: window) { returnCode in
249 | NSApp.stopModal(withCode: returnCode)
250 | }
251 |
252 | return NSApp.runModal(for: window)
253 | }
254 | }
255 |
256 |
257 | enum UnicodeSymbols {
258 | /**
259 | Represents the Function (Fn) key on the keybord.
260 | */
261 | static let functionKey = "🌐\u{FE0E}"
262 | }
263 |
264 |
265 | extension NSEvent.ModifierFlags {
266 | // Not documented anywhere, but reverse-engineered by me.
267 | private static let functionKey = 1 << 17 // 131072 (0x20000)
268 |
269 | var carbon: Int {
270 | var modifierFlags = 0
271 |
272 | if contains(.control) {
273 | modifierFlags |= controlKey
274 | }
275 |
276 | if contains(.option) {
277 | modifierFlags |= optionKey
278 | }
279 |
280 | if contains(.shift) {
281 | modifierFlags |= shiftKey
282 | }
283 |
284 | if contains(.command) {
285 | modifierFlags |= cmdKey
286 | }
287 |
288 | if contains(.function) {
289 | modifierFlags |= Self.functionKey
290 | }
291 |
292 | return modifierFlags
293 | }
294 |
295 | init(carbon: Int) {
296 | self.init()
297 |
298 | if carbon & controlKey == controlKey {
299 | insert(.control)
300 | }
301 |
302 | if carbon & optionKey == optionKey {
303 | insert(.option)
304 | }
305 |
306 | if carbon & shiftKey == shiftKey {
307 | insert(.shift)
308 | }
309 |
310 | if carbon & cmdKey == cmdKey {
311 | insert(.command)
312 | }
313 |
314 | if carbon & Self.functionKey == Self.functionKey {
315 | insert(.function)
316 | }
317 | }
318 | }
319 |
320 | extension SwiftUI.EventModifiers {
321 | // `.function` is deprecated, so we use the raw value.
322 | fileprivate static let function_nonDeprecated = Self(rawValue: 64)
323 | }
324 |
325 | extension NSEvent.ModifierFlags {
326 | var toEventModifiers: SwiftUI.EventModifiers {
327 | var modifiers = SwiftUI.EventModifiers()
328 |
329 | if contains(.capsLock) {
330 | modifiers.insert(.capsLock)
331 | }
332 |
333 | if contains(.command) {
334 | modifiers.insert(.command)
335 | }
336 |
337 | if contains(.control) {
338 | modifiers.insert(.control)
339 | }
340 |
341 | if contains(.numericPad) {
342 | modifiers.insert(.numericPad)
343 | }
344 |
345 | if contains(.option) {
346 | modifiers.insert(.option)
347 | }
348 |
349 | if contains(.shift) {
350 | modifiers.insert(.shift)
351 | }
352 |
353 | if contains(.function) {
354 | modifiers.insert(.function_nonDeprecated)
355 | }
356 |
357 | return modifiers
358 | }
359 | }
360 |
361 | extension NSEvent.ModifierFlags {
362 | /**
363 | The string representation of the modifier flags.
364 |
365 | ```
366 | print(NSEvent.ModifierFlags([.command, .shift]))
367 | //=> "⇧⌘"
368 | ```
369 | */
370 | var presentableDescription: String {
371 | var description = ""
372 |
373 | if contains(.control) {
374 | description += "⌃"
375 | }
376 |
377 | if contains(.option) {
378 | description += "⌥"
379 | }
380 |
381 | if contains(.shift) {
382 | description += "⇧"
383 | }
384 |
385 | if contains(.command) {
386 | description += "⌘"
387 | }
388 |
389 | if contains(.function) {
390 | description += UnicodeSymbols.functionKey
391 | }
392 |
393 | return description
394 | }
395 | }
396 |
397 |
398 | extension NSEvent.SpecialKey {
399 | static let functionKeys: Set = [
400 | .f1,
401 | .f2,
402 | .f3,
403 | .f4,
404 | .f5,
405 | .f6,
406 | .f7,
407 | .f8,
408 | .f9,
409 | .f10,
410 | .f11,
411 | .f12,
412 | .f13,
413 | .f14,
414 | .f15,
415 | .f16,
416 | .f17,
417 | .f18,
418 | .f19,
419 | .f20,
420 | .f21,
421 | .f22,
422 | .f23,
423 | .f24,
424 | .f25,
425 | .f26,
426 | .f27,
427 | .f28,
428 | .f29,
429 | .f30,
430 | .f31,
431 | .f32,
432 | .f33,
433 | .f34,
434 | .f35
435 | ]
436 |
437 | var isFunctionKey: Bool { Self.functionKeys.contains(self) }
438 | }
439 |
440 |
441 | enum AssociationPolicy {
442 | case assign
443 | case retainNonatomic
444 | case copyNonatomic
445 | case retain
446 | case copy
447 |
448 | var rawValue: objc_AssociationPolicy {
449 | switch self {
450 | case .assign:
451 | .OBJC_ASSOCIATION_ASSIGN
452 | case .retainNonatomic:
453 | .OBJC_ASSOCIATION_RETAIN_NONATOMIC
454 | case .copyNonatomic:
455 | .OBJC_ASSOCIATION_COPY_NONATOMIC
456 | case .retain:
457 | .OBJC_ASSOCIATION_RETAIN
458 | case .copy:
459 | .OBJC_ASSOCIATION_COPY
460 | }
461 | }
462 | }
463 |
464 | final class ObjectAssociation {
465 | private let policy: AssociationPolicy
466 |
467 | init(policy: AssociationPolicy = .retainNonatomic) {
468 | self.policy = policy
469 | }
470 |
471 | subscript(index: AnyObject) -> T? {
472 | get {
473 | // Force-cast is fine here as we want it to fail loudly if we don't use the correct type.
474 | // swiftlint:disable:next force_cast
475 | objc_getAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque()) as! T?
476 | }
477 | set {
478 | objc_setAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque(), newValue, policy.rawValue)
479 | }
480 | }
481 | }
482 |
483 |
484 | extension HorizontalAlignment {
485 | private enum ControlAlignment: AlignmentID {
486 | static func defaultValue(in context: ViewDimensions) -> CGFloat { // swiftlint:disable:this no_cgfloat
487 | context[HorizontalAlignment.center]
488 | }
489 | }
490 |
491 | fileprivate static let controlAlignment = Self(ControlAlignment.self)
492 | }
493 |
494 | extension View {
495 | func formLabel(@ViewBuilder _ label: () -> some View) -> some View {
496 | HStack(alignment: .firstTextBaseline) {
497 | label()
498 | labelsHidden()
499 | .alignmentGuide(.controlAlignment) { $0[.leading] }
500 | }
501 | .alignmentGuide(.leading) { $0[.controlAlignment] }
502 | }
503 | }
504 |
505 |
506 | extension Dictionary {
507 | func hasKey(_ key: Key) -> Bool {
508 | index(forKey: key) != nil
509 | }
510 | }
511 | #endif
512 |
513 |
514 | @available(iOS 14.0, *)
515 | @available(macOS 11.0, *)
516 | extension KeyEquivalent {
517 | init?(unicodeScalarValue value: Int) {
518 | guard let character = Character(unicodeScalarValue: value) else {
519 | return nil
520 | }
521 |
522 | self = KeyEquivalent(character)
523 | }
524 | }
525 |
526 |
527 | extension Sequence where Element: Hashable {
528 | /**
529 | Convert a `Sequence` with `Hashable` elements to a `Set`.
530 | */
531 | func toSet() -> Set { Set(self) }
532 | }
533 |
534 |
535 | extension Set {
536 | /**
537 | Convert a `Set` to an `Array`.
538 | */
539 | func toArray() -> [Element] { Array(self) }
540 | }
541 |
542 |
543 | extension StringProtocol {
544 | func replacingPrefix(_ prefix: String, with replacement: String) -> String {
545 | guard hasPrefix(prefix) else {
546 | return String(self)
547 | }
548 |
549 | return replacement + dropFirst(prefix.count)
550 | }
551 | }
552 |
553 | extension Character {
554 | init?(unicodeScalarValue value: Int) {
555 | guard let content = UnicodeScalar(value) else {
556 | return nil
557 | }
558 |
559 | self = Character(content)
560 | }
561 | }
562 |
--------------------------------------------------------------------------------
/Sources/KeyboardShortcuts/ViewModifiers.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import SwiftUI
3 |
4 | @available(macOS 12, *)
5 | extension View {
6 | /**
7 | Renamed to `onGlobalKeyboardShortcut`.
8 | */
9 | @available(*, deprecated, renamed: "onGlobalKeyboardShortcut")
10 | public func onKeyboardShortcut(
11 | _ shortcut: KeyboardShortcuts.Name,
12 | perform: @escaping (KeyboardShortcuts.EventType) -> Void
13 | ) -> some View {
14 | task {
15 | for await eventType in KeyboardShortcuts.events(for: shortcut) {
16 | perform(eventType)
17 | }
18 | }
19 | }
20 |
21 | /**
22 | Renamed to `onGlobalKeyboardShortcut`.
23 | */
24 | @available(*, deprecated, renamed: "onGlobalKeyboardShortcut")
25 | public func onKeyboardShortcut(
26 | _ shortcut: KeyboardShortcuts.Name,
27 | type: KeyboardShortcuts.EventType,
28 | perform: @escaping () -> Void
29 | ) -> some View {
30 | task {
31 | for await _ in KeyboardShortcuts.events(type, for: shortcut) {
32 | perform()
33 | }
34 | }
35 | }
36 | }
37 |
38 | @available(macOS 12, *)
39 | extension View {
40 | /**
41 | Register a listener for keyboard shortcut events with the given name.
42 |
43 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
44 |
45 | The listener will stop automatically when the view disappears.
46 |
47 | - Note: This method is not affected by `.removeAllHandlers()`.
48 | */
49 | public func onGlobalKeyboardShortcut(
50 | _ shortcut: KeyboardShortcuts.Name,
51 | perform: @escaping (KeyboardShortcuts.EventType) -> Void
52 | ) -> some View {
53 | task {
54 | for await eventType in KeyboardShortcuts.events(for: shortcut) {
55 | perform(eventType)
56 | }
57 | }
58 | }
59 |
60 | /**
61 | Register a listener for keyboard shortcut events with the given name and type.
62 |
63 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
64 |
65 | The listener will stop automatically when the view disappears.
66 |
67 | - Note: This method is not affected by `.removeAllHandlers()`.
68 | */
69 | public func onGlobalKeyboardShortcut(
70 | _ shortcut: KeyboardShortcuts.Name,
71 | type: KeyboardShortcuts.EventType,
72 | perform: @escaping () -> Void
73 | ) -> some View {
74 | task {
75 | for await _ in KeyboardShortcuts.events(type, for: shortcut) {
76 | perform()
77 | }
78 | }
79 | }
80 | }
81 |
82 | @available(macOS 12.3, *)
83 | extension View {
84 | /**
85 | Associates a global keyboard shortcut with a control.
86 |
87 | This is mostly useful to have the keyboard shortcut show for a `Button` in a `Menu` or `MenuBarExtra`.
88 |
89 | It does not trigger the control's action.
90 |
91 | - Important: Do not use it in a `CommandGroup` as the shortcut recorder will think the shortcut is already taken. It does remove the shortcut while the recorder is active, but because of a bug in macOS 15, the state is not reflected correctly in the underlying menu item.
92 | */
93 | public func globalKeyboardShortcut(_ name: KeyboardShortcuts.Name) -> some View {
94 | modifier(GlobalKeyboardShortcutViewModifier(name: name))
95 | }
96 | }
97 |
98 | @available(macOS 12.3, *)
99 | private struct GlobalKeyboardShortcutViewModifier: ViewModifier {
100 | @State private var isRecorderActive = false
101 | @State private var triggerRefresh = false
102 |
103 | let name: KeyboardShortcuts.Name
104 |
105 | func body(content: Content) -> some View {
106 | content
107 | .keyboardShortcut(isRecorderActive ? nil : name.shortcut?.toSwiftUI)
108 | .id(triggerRefresh)
109 | .onReceive(NotificationCenter.default.publisher(for: .shortcutByNameDidChange)) {
110 | guard $0.userInfo?["name"] as? KeyboardShortcuts.Name == name else {
111 | return
112 | }
113 |
114 | triggerRefresh.toggle()
115 | }
116 | .onReceive(NotificationCenter.default.publisher(for: .recorderActiveStatusDidChange)) {
117 | isRecorderActive = $0.userInfo?["isActive"] as? Bool ?? false
118 | }
119 | }
120 | }
121 | #endif
122 |
--------------------------------------------------------------------------------
/Tests/KeyboardShortcutsTests/KeyboardShortcutsTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import KeyboardShortcuts
3 |
4 | final class KeyboardShortcutsTests: XCTestCase {
5 | // TODO: Add more tests.
6 |
7 | override func setUpWithError() throws {
8 | UserDefaults.standard.removeAll()
9 | }
10 |
11 | func testSetShortcutAndReset() throws {
12 | let defaultShortcut = KeyboardShortcuts.Shortcut(.c)
13 | let shortcut1 = KeyboardShortcuts.Shortcut(.a)
14 | let shortcut2 = KeyboardShortcuts.Shortcut(.b)
15 |
16 | let shortcutName1 = KeyboardShortcuts.Name("testSetShortcutAndReset1")
17 | let shortcutName2 = KeyboardShortcuts.Name("testSetShortcutAndReset2", default: defaultShortcut)
18 |
19 | KeyboardShortcuts.setShortcut(shortcut1, for: shortcutName1)
20 | KeyboardShortcuts.setShortcut(shortcut2, for: shortcutName2)
21 |
22 | XCTAssertEqual(KeyboardShortcuts.getShortcut(for: shortcutName1), shortcut1)
23 | XCTAssertEqual(KeyboardShortcuts.getShortcut(for: shortcutName2), shortcut2)
24 |
25 | KeyboardShortcuts.reset(shortcutName1, shortcutName2)
26 |
27 | XCTAssertNil(KeyboardShortcuts.getShortcut(for: shortcutName1))
28 | XCTAssertEqual(KeyboardShortcuts.getShortcut(for: shortcutName2), defaultShortcut)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/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/sindresorhus/KeyboardShortcuts/2927f7037492c193111e753c41fc5588b3317007/logo-dark.png
--------------------------------------------------------------------------------
/logo-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sindresorhus/KeyboardShortcuts/2927f7037492c193111e753c41fc5588b3317007/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.15+
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 | @State 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 | @Observable
82 | final class AppState {
83 | init() {
84 | KeyboardShortcuts.onKeyUp(for: .toggleUnicornMode) { [self] in
85 | isUnicornMode.toggle()
86 | }
87 | }
88 | }
89 | ```
90 |
91 | *You can also listen to key down with `.onKeyDown()`*
92 |
93 | **That's all! ✨**
94 |
95 | You can find a complete example in the “Example” directory.
96 |
97 | You can also find a [real-world example](https://github.com/sindresorhus/Plash/blob/b348a62645a873abba8dc11ff0fb8fe423419411/Plash/PreferencesView.swift#L121-L130) in my Plash app.
98 |
99 | #### Cocoa
100 |
101 | Using [`KeyboardShortcuts.RecorderCocoa`](Sources/KeyboardShortcuts/RecorderCocoa.swift) instead of `KeyboardShortcuts.Recorder`:
102 |
103 | ```swift
104 | import AppKit
105 | import KeyboardShortcuts
106 |
107 | final class SettingsViewController: NSViewController {
108 | override func loadView() {
109 | view = NSView()
110 |
111 | let recorder = KeyboardShortcuts.RecorderCocoa(for: .toggleUnicornMode)
112 | view.addSubview(recorder)
113 | }
114 | }
115 | ```
116 |
117 | ## Localization
118 |
119 | This package supports [localizations](/Sources/KeyboardShortcuts/Localization). PR welcome for more!
120 |
121 | 1. Fork the repo.
122 | 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)
123 | 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.
124 | 4. Localize and make sure to review your localization multiple times. Check for typos.
125 | 5. Try to find someone that speaks your language to review the translation.
126 | 6. Submit a PR.
127 |
128 | ## API
129 |
130 | [See the API docs.](https://swiftpackageindex.com/sindresorhus/KeyboardShortcuts/documentation/keyboardshortcuts/keyboardshortcuts)
131 |
132 | ## Tips
133 |
134 | #### Show a recorded keyboard shortcut in an `NSMenuItem`
135 |
136 |
137 |
138 | See [`NSMenuItem#setShortcut`](https://github.com/sindresorhus/KeyboardShortcuts/blob/0dcedd56994d871f243f3d9c76590bfd9f8aba69/Sources/KeyboardShortcuts/NSMenuItem%2B%2B.swift#L14-L41).
139 |
140 | #### Dynamic keyboard shortcuts
141 |
142 | 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.
143 |
144 | #### Default keyboard shortcuts
145 |
146 | 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.
147 |
148 | ```swift
149 | import KeyboardShortcuts
150 |
151 | extension KeyboardShortcuts.Name {
152 | static let toggleUnicornMode = Self("toggleUnicornMode", default: .init(.k, modifiers: [.command, .option]))
153 | }
154 | ```
155 |
156 | #### Get all keyboard shortcuts
157 |
158 | To get all the keyboard shortcut `Name`'s, conform `KeyboardShortcuts.Name` to `CaseIterable`.
159 |
160 | ```swift
161 | import KeyboardShortcuts
162 |
163 | extension KeyboardShortcuts.Name {
164 | static let foo = Self("foo")
165 | static let bar = Self("bar")
166 | }
167 |
168 | extension KeyboardShortcuts.Name: CaseIterable {
169 | public static let allCases: [Self] = [
170 | .foo,
171 | .bar
172 | ]
173 | }
174 |
175 | // …
176 |
177 | print(KeyboardShortcuts.Name.allCases)
178 | ```
179 |
180 | And to get all the `Name`'s with a set keyboard shortcut:
181 |
182 | ```swift
183 | print(KeyboardShortcuts.Name.allCases.filter { $0.shortcut != nil })
184 | ```
185 |
186 | ## FAQ
187 |
188 | #### How is it different from [`MASShortcut`](https://github.com/shpakovski/MASShortcut)?
189 |
190 | This package:
191 | - Written in Swift with a swifty API.
192 | - More native-looking UI component.
193 | - SwiftUI component included.
194 | - Support for listening to key down, not just key up.
195 | - Swift Package Manager support.
196 | - Connect a shortcut to an `NSMenuItem`.
197 | - Works when [`NSMenu` is open](https://github.com/sindresorhus/KeyboardShortcuts/issues/1) (e.g. menu bar apps).
198 |
199 | `MASShortcut`:
200 | - More mature.
201 | - More localizations.
202 |
203 | #### How is it different from [`HotKey`](https://github.com/soffes/HotKey)?
204 |
205 | `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.
206 |
207 | #### Why is this package importing `Carbon`? Isn't that deprecated?
208 |
209 | 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.
210 |
211 | #### Does this package cause any permission dialogs?
212 |
213 | No.
214 |
215 | #### How can I add an app-specific keyboard shortcut that is only active when the app is?
216 |
217 | 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(_:)).
218 |
219 | #### Does it support media keys?
220 |
221 | No, since it would not work for sandboxed apps. If your app is not sandboxed, you can use [`MediaKeyTap`](https://github.com/nhurden/MediaKeyTap).
222 |
223 | #### Can you support CocoaPods or Carthage?
224 |
225 | No. However, there is nothing stopping you from using Swift Package Manager for just this package even if you normally use CocoaPods or Carthage.
226 |
227 | ## Related
228 |
229 | - [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults
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+archived%3Afalse&type=repositories)
232 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sindresorhus/KeyboardShortcuts/2927f7037492c193111e753c41fc5588b3317007/screenshot.png
--------------------------------------------------------------------------------