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