├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── swiftlint.yml ├── .gitignore ├── .spi.yml ├── .swiftlint.yml ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── KeyboardShortcuts.xcscheme ├── Example ├── KeyboardShortcutsExample.xcodeproj │ └── project.pbxproj └── KeyboardShortcutsExample │ ├── App.swift │ ├── AppState.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── KeyboardShortcutsExample.entitlements │ ├── MainScreen.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── Utilities.swift ├── Package.swift ├── Sources └── KeyboardShortcuts │ ├── CarbonKeyboardShortcuts.swift │ ├── Key.swift │ ├── KeyboardShortcuts.swift │ ├── Localization │ ├── ar.lproj │ │ └── Localizable.strings │ ├── cs.lproj │ │ └── Localizable.strings │ ├── de.lproj │ │ └── Localizable.strings │ ├── en.lproj │ │ └── Localizable.strings │ ├── es.lproj │ │ └── Localizable.strings │ ├── fr.lproj │ │ └── Localizable.strings │ ├── hu.lproj │ │ └── Localizable.strings │ ├── ja.lproj │ │ └── Localizable.strings │ ├── ko.lproj │ │ └── Localizable.strings │ ├── nl.lproj │ │ └── Localizable.strings │ ├── pt-BR.lproj │ │ └── Localizable.strings │ ├── ru.lproj │ │ └── Localizable.strings │ ├── sk.lproj │ │ └── Localizable.strings │ ├── zh-Hans.lproj │ │ └── Localizable.strings │ └── zh-TW.lproj │ │ └── Localizable.strings │ ├── NSMenuItem++.swift │ ├── Name.swift │ ├── Recorder.swift │ ├── RecorderCocoa.swift │ ├── Shortcut.swift │ ├── Utilities.swift │ └── ViewModifiers.swift ├── Tests └── KeyboardShortcutsTests │ ├── KeyboardShortcutsTests.swift │ └── Utilities.swift ├── license ├── logo-dark.png ├── logo-light.png ├── readme.md └── screenshot.png /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/swiftlint.yml: -------------------------------------------------------------------------------- 1 | name: SwiftLint 2 | on: 3 | pull_request: 4 | paths: 5 | - '.github/workflows/swiftlint.yml' 6 | - '.swiftlint.yml' 7 | - '**/*.swift' 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: SwiftLint 14 | uses: norio-nomura/action-swiftlint@3.2.1 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.build 2 | /Packages 3 | xcuserdata 4 | project.xcworkspace 5 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: ['KeyboardShortcuts'] 5 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | only_rules: 2 | - accessibility_trait_for_button 3 | - array_init 4 | - blanket_disable_command 5 | - block_based_kvo 6 | - class_delegate_protocol 7 | - closing_brace 8 | - closure_end_indentation 9 | - closure_parameter_position 10 | - closure_spacing 11 | - collection_alignment 12 | - colon 13 | - comma 14 | - comma_inheritance 15 | - compiler_protocol_init 16 | - computed_accessors_order 17 | - conditional_returns_on_newline 18 | - contains_over_filter_count 19 | - contains_over_filter_is_empty 20 | - contains_over_first_not_nil 21 | - contains_over_range_nil_comparison 22 | - control_statement 23 | - custom_rules 24 | - deployment_target 25 | - direct_return 26 | - discarded_notification_center_observer 27 | - discouraged_assert 28 | - discouraged_direct_init 29 | - discouraged_none_name 30 | - discouraged_object_literal 31 | - discouraged_optional_boolean 32 | - discouraged_optional_collection 33 | - duplicate_conditions 34 | - duplicate_enum_cases 35 | - duplicate_imports 36 | - duplicated_key_in_dictionary_literal 37 | - dynamic_inline 38 | - empty_collection_literal 39 | - empty_count 40 | - empty_enum_arguments 41 | - empty_parameters 42 | - empty_parentheses_with_trailing_closure 43 | - empty_string 44 | - empty_xctest_method 45 | - enum_case_associated_values_count 46 | - explicit_init 47 | - fallthrough 48 | - fatal_error_message 49 | - final_test_case 50 | - first_where 51 | - flatmap_over_map_reduce 52 | - for_where 53 | - generic_type_name 54 | - ibinspectable_in_extension 55 | - identical_operands 56 | - identifier_name 57 | - implicit_getter 58 | - implicit_return 59 | - inclusive_language 60 | - invalid_swiftlint_command 61 | - is_disjoint 62 | - joined_default_parameter 63 | - last_where 64 | - leading_whitespace 65 | - legacy_cggeometry_functions 66 | - legacy_constant 67 | - legacy_constructor 68 | - legacy_hashing 69 | - legacy_multiple 70 | - legacy_nsgeometry_functions 71 | - legacy_random 72 | - literal_expression_end_indentation 73 | - lower_acl_than_parent 74 | - mark 75 | - modifier_order 76 | - multiline_arguments 77 | - multiline_arguments_brackets 78 | - multiline_function_chains 79 | - multiline_literal_brackets 80 | - multiline_parameters 81 | - multiline_parameters_brackets 82 | - nimble_operator 83 | - no_extension_access_modifier 84 | - no_fallthrough_only 85 | - no_space_in_method_call 86 | - non_optional_string_data_conversion 87 | - non_overridable_class_declaration 88 | - notification_center_detachment 89 | - ns_number_init_as_function_reference 90 | - nsobject_prefer_isequal 91 | - number_separator 92 | - operator_usage_whitespace 93 | - operator_whitespace 94 | - overridden_super_call 95 | - prefer_self_in_static_references 96 | - prefer_self_type_over_type_of_self 97 | - prefer_zero_over_explicit_init 98 | - private_action 99 | - private_outlet 100 | - private_subject 101 | - private_swiftui_state 102 | - private_unit_test 103 | - prohibited_super_call 104 | - protocol_property_accessors_order 105 | - reduce_boolean 106 | - reduce_into 107 | - redundant_discardable_let 108 | - redundant_nil_coalescing 109 | - redundant_objc_attribute 110 | - redundant_optional_initialization 111 | - redundant_set_access_control 112 | - redundant_string_enum_value 113 | - redundant_type_annotation 114 | - redundant_void_return 115 | - required_enum_case 116 | - return_arrow_whitespace 117 | - return_value_from_void_function 118 | - self_binding 119 | - self_in_property_initialization 120 | - shorthand_operator 121 | - shorthand_optional_binding 122 | - sorted_first_last 123 | - statement_position 124 | - static_operator 125 | - static_over_final_class 126 | - strong_iboutlet 127 | - superfluous_disable_command 128 | - superfluous_else 129 | - switch_case_alignment 130 | - switch_case_on_newline 131 | - syntactic_sugar 132 | - test_case_accessibility 133 | - toggle_bool 134 | - trailing_closure 135 | - trailing_comma 136 | - trailing_newline 137 | - trailing_semicolon 138 | - trailing_whitespace 139 | - unavailable_condition 140 | - unavailable_function 141 | - unneeded_break_in_switch 142 | - unneeded_override 143 | - unneeded_parentheses_in_closure_argument 144 | - unowned_variable_capture 145 | - untyped_error_in_catch 146 | - unused_closure_parameter 147 | - unused_control_flow_label 148 | - unused_enumerated 149 | - unused_optional_binding 150 | - unused_setter_value 151 | - valid_ibinspectable 152 | - vertical_parameter_alignment 153 | - vertical_parameter_alignment_on_call 154 | - vertical_whitespace_closing_braces 155 | - vertical_whitespace_opening_braces 156 | - void_function_in_ternary 157 | - void_return 158 | - xct_specific_matcher 159 | - xctfail_message 160 | - yoda_condition 161 | analyzer_rules: 162 | - capture_variable 163 | - typesafe_array_init 164 | - unneeded_synthesized_initializer 165 | - unused_declaration 166 | - unused_import 167 | for_where: 168 | allow_for_as_filter: true 169 | number_separator: 170 | minimum_length: 5 171 | identifier_name: 172 | max_length: 173 | warning: 100 174 | error: 100 175 | min_length: 176 | warning: 2 177 | error: 2 178 | allowed_symbols: 179 | - '_' 180 | excluded: 181 | - 'x' 182 | - 'y' 183 | - 'z' 184 | - 'a' 185 | - 'b' 186 | - 'x1' 187 | - 'x2' 188 | - 'y1' 189 | - 'y2' 190 | - 'z2' 191 | redundant_type_annotation: 192 | consider_default_literal_types_redundant: true 193 | unneeded_override: 194 | affect_initializers: true 195 | deployment_target: 196 | macOS_deployment_target: '10.11' 197 | custom_rules: 198 | no_nsrect: 199 | regex: '\bNSRect\b' 200 | match_kinds: typeidentifier 201 | message: 'Use CGRect instead of NSRect' 202 | no_nssize: 203 | regex: '\bNSSize\b' 204 | match_kinds: typeidentifier 205 | message: 'Use CGSize instead of NSSize' 206 | no_nspoint: 207 | regex: '\bNSPoint\b' 208 | match_kinds: typeidentifier 209 | message: 'Use CGPoint instead of NSPoint' 210 | no_cgfloat: 211 | regex: '\bCGFloat\b' 212 | match_kinds: typeidentifier 213 | message: 'Use Double instead of CGFloat' 214 | no_cgfloat2: 215 | regex: '\bCGFloat\(' 216 | message: 'Use Double instead of CGFloat' 217 | swiftui_state_private: 218 | regex: '@(ObservedObject|EnvironmentObject)\s+var' 219 | message: 'SwiftUI @ObservedObject and @EnvironmentObject properties should be private' 220 | swiftui_environment_private: 221 | regex: '@Environment\(\\\.\w+\)\s+var' 222 | message: 'SwiftUI @Environment properties should be private' 223 | final_class: 224 | regex: '^class [a-zA-Z\d]+[^{]+\{' 225 | message: 'Classes should be marked as final whenever possible. If you actually need it to be subclassable, just add `// swiftlint:disable:next final_class`.' 226 | no_alignment_center: 227 | regex: '\b\(alignment: .center\b' 228 | message: 'This alignment is the default.' 229 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/KeyboardShortcuts.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 67 | 68 | 74 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Example/KeyboardShortcutsExample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | E36FB94A2609BA43004272D9 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB9492609BA43004272D9 /* App.swift */; }; 11 | E36FB94C2609BA43004272D9 /* MainScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB94B2609BA43004272D9 /* MainScreen.swift */; }; 12 | E36FB94E2609BA45004272D9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E36FB94D2609BA45004272D9 /* Assets.xcassets */; }; 13 | E36FB9512609BA45004272D9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E36FB9502609BA45004272D9 /* Preview Assets.xcassets */; }; 14 | E36FB9632609BB83004272D9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB9622609BB83004272D9 /* AppState.swift */; }; 15 | E36FB9662609BF3D004272D9 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36FB9652609BF3D004272D9 /* Utilities.swift */; }; 16 | E3A680512A042A0B00715D81 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = E3A680502A042A0B00715D81 /* KeyboardShortcuts */; }; 17 | /* End PBXBuildFile section */ 18 | 19 | /* Begin PBXFileReference section */ 20 | E33F1EFA26F3B78800ACEB0F /* KeyboardShortcuts */ = {isa = PBXFileReference; lastKnownFileType = folder; name = KeyboardShortcuts; path = ..; sourceTree = ""; }; 21 | E36FB9462609BA43004272D9 /* KeyboardShortcutsExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardShortcutsExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 22 | E36FB9492609BA43004272D9 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 23 | E36FB94B2609BA43004272D9 /* MainScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreen.swift; sourceTree = ""; }; 24 | E36FB94D2609BA45004272D9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 25 | E36FB9502609BA45004272D9 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 26 | E36FB9532609BA45004272D9 /* KeyboardShortcutsExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeyboardShortcutsExample.entitlements; sourceTree = ""; }; 27 | E36FB9622609BB83004272D9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 28 | E36FB9652609BF3D004272D9 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; 29 | /* End PBXFileReference section */ 30 | 31 | /* Begin PBXFrameworksBuildPhase section */ 32 | E36FB9432609BA43004272D9 /* Frameworks */ = { 33 | isa = PBXFrameworksBuildPhase; 34 | buildActionMask = 2147483647; 35 | files = ( 36 | E3A680512A042A0B00715D81 /* KeyboardShortcuts in Frameworks */, 37 | ); 38 | runOnlyForDeploymentPostprocessing = 0; 39 | }; 40 | /* End PBXFrameworksBuildPhase section */ 41 | 42 | /* Begin PBXGroup section */ 43 | E33F1EF926F3B78800ACEB0F /* Packages */ = { 44 | isa = PBXGroup; 45 | children = ( 46 | E33F1EFA26F3B78800ACEB0F /* KeyboardShortcuts */, 47 | ); 48 | name = Packages; 49 | sourceTree = ""; 50 | }; 51 | E36FB93D2609BA43004272D9 = { 52 | isa = PBXGroup; 53 | children = ( 54 | E36FB9482609BA43004272D9 /* KeyboardShortcutsExample */, 55 | E36FB9472609BA43004272D9 /* Products */, 56 | E33F1EF926F3B78800ACEB0F /* Packages */, 57 | E3A6804F2A042A0B00715D81 /* Frameworks */, 58 | ); 59 | sourceTree = ""; 60 | }; 61 | E36FB9472609BA43004272D9 /* Products */ = { 62 | isa = PBXGroup; 63 | children = ( 64 | E36FB9462609BA43004272D9 /* KeyboardShortcutsExample.app */, 65 | ); 66 | name = Products; 67 | sourceTree = ""; 68 | }; 69 | E36FB9482609BA43004272D9 /* KeyboardShortcutsExample */ = { 70 | isa = PBXGroup; 71 | children = ( 72 | E36FB9492609BA43004272D9 /* App.swift */, 73 | E36FB9622609BB83004272D9 /* AppState.swift */, 74 | E36FB94B2609BA43004272D9 /* MainScreen.swift */, 75 | E36FB9652609BF3D004272D9 /* Utilities.swift */, 76 | E36FB94D2609BA45004272D9 /* Assets.xcassets */, 77 | E36FB9532609BA45004272D9 /* KeyboardShortcutsExample.entitlements */, 78 | E36FB94F2609BA45004272D9 /* Preview Content */, 79 | ); 80 | path = KeyboardShortcutsExample; 81 | sourceTree = ""; 82 | }; 83 | E36FB94F2609BA45004272D9 /* Preview Content */ = { 84 | isa = PBXGroup; 85 | children = ( 86 | E36FB9502609BA45004272D9 /* Preview Assets.xcassets */, 87 | ); 88 | path = "Preview Content"; 89 | sourceTree = ""; 90 | }; 91 | E3A6804F2A042A0B00715D81 /* Frameworks */ = { 92 | isa = PBXGroup; 93 | children = ( 94 | ); 95 | name = Frameworks; 96 | sourceTree = ""; 97 | }; 98 | /* End PBXGroup section */ 99 | 100 | /* Begin PBXNativeTarget section */ 101 | E36FB9452609BA43004272D9 /* KeyboardShortcutsExample */ = { 102 | isa = PBXNativeTarget; 103 | buildConfigurationList = E36FB9562609BA45004272D9 /* Build configuration list for PBXNativeTarget "KeyboardShortcutsExample" */; 104 | buildPhases = ( 105 | E36FB9422609BA43004272D9 /* Sources */, 106 | E36FB9432609BA43004272D9 /* Frameworks */, 107 | E36FB9442609BA43004272D9 /* Resources */, 108 | ); 109 | buildRules = ( 110 | ); 111 | dependencies = ( 112 | ); 113 | name = KeyboardShortcutsExample; 114 | packageProductDependencies = ( 115 | E3A680502A042A0B00715D81 /* KeyboardShortcuts */, 116 | ); 117 | productName = KeyboardShortcutsExample; 118 | productReference = E36FB9462609BA43004272D9 /* KeyboardShortcutsExample.app */; 119 | productType = "com.apple.product-type.application"; 120 | }; 121 | /* End PBXNativeTarget section */ 122 | 123 | /* Begin PBXProject section */ 124 | E36FB93E2609BA43004272D9 /* Project object */ = { 125 | isa = PBXProject; 126 | attributes = { 127 | BuildIndependentTargetsInParallel = YES; 128 | LastSwiftUpdateCheck = 1240; 129 | LastUpgradeCheck = 1530; 130 | TargetAttributes = { 131 | E36FB9452609BA43004272D9 = { 132 | CreatedOnToolsVersion = 12.4; 133 | }; 134 | }; 135 | }; 136 | buildConfigurationList = E36FB9412609BA43004272D9 /* Build configuration list for PBXProject "KeyboardShortcutsExample" */; 137 | compatibilityVersion = "Xcode 14.0"; 138 | developmentRegion = en; 139 | hasScannedForEncodings = 0; 140 | knownRegions = ( 141 | en, 142 | Base, 143 | ); 144 | mainGroup = E36FB93D2609BA43004272D9; 145 | productRefGroup = E36FB9472609BA43004272D9 /* Products */; 146 | projectDirPath = ""; 147 | projectRoot = ""; 148 | targets = ( 149 | E36FB9452609BA43004272D9 /* KeyboardShortcutsExample */, 150 | ); 151 | }; 152 | /* End PBXProject section */ 153 | 154 | /* Begin PBXResourcesBuildPhase section */ 155 | E36FB9442609BA43004272D9 /* Resources */ = { 156 | isa = PBXResourcesBuildPhase; 157 | buildActionMask = 2147483647; 158 | files = ( 159 | E36FB9512609BA45004272D9 /* Preview Assets.xcassets in Resources */, 160 | E36FB94E2609BA45004272D9 /* Assets.xcassets in Resources */, 161 | ); 162 | runOnlyForDeploymentPostprocessing = 0; 163 | }; 164 | /* End PBXResourcesBuildPhase section */ 165 | 166 | /* Begin PBXSourcesBuildPhase section */ 167 | E36FB9422609BA43004272D9 /* Sources */ = { 168 | isa = PBXSourcesBuildPhase; 169 | buildActionMask = 2147483647; 170 | files = ( 171 | E36FB9632609BB83004272D9 /* AppState.swift in Sources */, 172 | E36FB94C2609BA43004272D9 /* MainScreen.swift in Sources */, 173 | E36FB9662609BF3D004272D9 /* Utilities.swift in Sources */, 174 | E36FB94A2609BA43004272D9 /* App.swift in Sources */, 175 | ); 176 | runOnlyForDeploymentPostprocessing = 0; 177 | }; 178 | /* End PBXSourcesBuildPhase section */ 179 | 180 | /* Begin XCBuildConfiguration section */ 181 | E36FB9542609BA45004272D9 /* Debug */ = { 182 | isa = XCBuildConfiguration; 183 | buildSettings = { 184 | ALWAYS_SEARCH_USER_PATHS = NO; 185 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 186 | CLANG_ANALYZER_NONNULL = YES; 187 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 188 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 189 | CLANG_CXX_LIBRARY = "libc++"; 190 | CLANG_ENABLE_MODULES = YES; 191 | CLANG_ENABLE_OBJC_ARC = YES; 192 | CLANG_ENABLE_OBJC_WEAK = YES; 193 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 194 | CLANG_WARN_BOOL_CONVERSION = YES; 195 | CLANG_WARN_COMMA = YES; 196 | CLANG_WARN_CONSTANT_CONVERSION = YES; 197 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 198 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 199 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 200 | CLANG_WARN_EMPTY_BODY = YES; 201 | CLANG_WARN_ENUM_CONVERSION = YES; 202 | CLANG_WARN_INFINITE_RECURSION = YES; 203 | CLANG_WARN_INT_CONVERSION = YES; 204 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 205 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 206 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 207 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 208 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 209 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 210 | CLANG_WARN_STRICT_PROTOTYPES = YES; 211 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 212 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 213 | CLANG_WARN_UNREACHABLE_CODE = YES; 214 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 215 | COPY_PHASE_STRIP = NO; 216 | DEAD_CODE_STRIPPING = YES; 217 | DEBUG_INFORMATION_FORMAT = dwarf; 218 | ENABLE_STRICT_OBJC_MSGSEND = YES; 219 | ENABLE_TESTABILITY = YES; 220 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 221 | GCC_C_LANGUAGE_STANDARD = gnu11; 222 | GCC_DYNAMIC_NO_PIC = NO; 223 | GCC_NO_COMMON_BLOCKS = YES; 224 | GCC_OPTIMIZATION_LEVEL = 0; 225 | GCC_PREPROCESSOR_DEFINITIONS = ( 226 | "DEBUG=1", 227 | "$(inherited)", 228 | ); 229 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 230 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 231 | GCC_WARN_UNDECLARED_SELECTOR = YES; 232 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 233 | GCC_WARN_UNUSED_FUNCTION = YES; 234 | GCC_WARN_UNUSED_VARIABLE = YES; 235 | MACOSX_DEPLOYMENT_TARGET = 14.4; 236 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 237 | MTL_FAST_MATH = YES; 238 | ONLY_ACTIVE_ARCH = YES; 239 | SDKROOT = macosx; 240 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 241 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 242 | }; 243 | name = Debug; 244 | }; 245 | E36FB9552609BA45004272D9 /* Release */ = { 246 | isa = XCBuildConfiguration; 247 | buildSettings = { 248 | ALWAYS_SEARCH_USER_PATHS = NO; 249 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 250 | CLANG_ANALYZER_NONNULL = YES; 251 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 252 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 253 | CLANG_CXX_LIBRARY = "libc++"; 254 | CLANG_ENABLE_MODULES = YES; 255 | CLANG_ENABLE_OBJC_ARC = YES; 256 | CLANG_ENABLE_OBJC_WEAK = YES; 257 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 258 | CLANG_WARN_BOOL_CONVERSION = YES; 259 | CLANG_WARN_COMMA = YES; 260 | CLANG_WARN_CONSTANT_CONVERSION = YES; 261 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 262 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 263 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 264 | CLANG_WARN_EMPTY_BODY = YES; 265 | CLANG_WARN_ENUM_CONVERSION = YES; 266 | CLANG_WARN_INFINITE_RECURSION = YES; 267 | CLANG_WARN_INT_CONVERSION = YES; 268 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 269 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 270 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 271 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 272 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 273 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 274 | CLANG_WARN_STRICT_PROTOTYPES = YES; 275 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 276 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 277 | CLANG_WARN_UNREACHABLE_CODE = YES; 278 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 279 | COPY_PHASE_STRIP = NO; 280 | DEAD_CODE_STRIPPING = YES; 281 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 282 | ENABLE_NS_ASSERTIONS = NO; 283 | ENABLE_STRICT_OBJC_MSGSEND = YES; 284 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 285 | GCC_C_LANGUAGE_STANDARD = gnu11; 286 | GCC_NO_COMMON_BLOCKS = YES; 287 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 288 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 289 | GCC_WARN_UNDECLARED_SELECTOR = YES; 290 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 291 | GCC_WARN_UNUSED_FUNCTION = YES; 292 | GCC_WARN_UNUSED_VARIABLE = YES; 293 | MACOSX_DEPLOYMENT_TARGET = 14.4; 294 | MTL_ENABLE_DEBUG_INFO = NO; 295 | MTL_FAST_MATH = YES; 296 | SDKROOT = macosx; 297 | SWIFT_COMPILATION_MODE = wholemodule; 298 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 299 | }; 300 | name = Release; 301 | }; 302 | E36FB9572609BA45004272D9 /* Debug */ = { 303 | isa = XCBuildConfiguration; 304 | buildSettings = { 305 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 306 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 307 | CODE_SIGN_ENTITLEMENTS = KeyboardShortcutsExample/KeyboardShortcutsExample.entitlements; 308 | CODE_SIGN_IDENTITY = "-"; 309 | CODE_SIGN_STYLE = Automatic; 310 | COMBINE_HIDPI_IMAGES = YES; 311 | CURRENT_PROJECT_VERSION = 1; 312 | DEAD_CODE_STRIPPING = YES; 313 | DEVELOPMENT_ASSET_PATHS = "\"KeyboardShortcutsExample/Preview Content\""; 314 | DEVELOPMENT_TEAM = ""; 315 | ENABLE_HARDENED_RUNTIME = YES; 316 | ENABLE_PREVIEWS = YES; 317 | GENERATE_INFOPLIST_FILE = YES; 318 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 319 | LD_RUNPATH_SEARCH_PATHS = ( 320 | "$(inherited)", 321 | "@executable_path/../Frameworks", 322 | ); 323 | MARKETING_VERSION = 1.0.0; 324 | PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.KeyboardShortcutsExample; 325 | PRODUCT_NAME = "$(TARGET_NAME)"; 326 | SWIFT_STRICT_CONCURRENCY = complete; 327 | SWIFT_VERSION = 5.0; 328 | }; 329 | name = Debug; 330 | }; 331 | E36FB9582609BA45004272D9 /* Release */ = { 332 | isa = XCBuildConfiguration; 333 | buildSettings = { 334 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 335 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 336 | CODE_SIGN_ENTITLEMENTS = KeyboardShortcutsExample/KeyboardShortcutsExample.entitlements; 337 | CODE_SIGN_IDENTITY = "-"; 338 | CODE_SIGN_STYLE = Automatic; 339 | COMBINE_HIDPI_IMAGES = YES; 340 | CURRENT_PROJECT_VERSION = 1; 341 | DEAD_CODE_STRIPPING = YES; 342 | DEVELOPMENT_ASSET_PATHS = "\"KeyboardShortcutsExample/Preview Content\""; 343 | DEVELOPMENT_TEAM = ""; 344 | ENABLE_HARDENED_RUNTIME = YES; 345 | ENABLE_PREVIEWS = YES; 346 | GENERATE_INFOPLIST_FILE = YES; 347 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 348 | LD_RUNPATH_SEARCH_PATHS = ( 349 | "$(inherited)", 350 | "@executable_path/../Frameworks", 351 | ); 352 | MARKETING_VERSION = 1.0.0; 353 | PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.KeyboardShortcutsExample; 354 | PRODUCT_NAME = "$(TARGET_NAME)"; 355 | SWIFT_STRICT_CONCURRENCY = complete; 356 | SWIFT_VERSION = 5.0; 357 | }; 358 | name = Release; 359 | }; 360 | /* End XCBuildConfiguration section */ 361 | 362 | /* Begin XCConfigurationList section */ 363 | E36FB9412609BA43004272D9 /* Build configuration list for PBXProject "KeyboardShortcutsExample" */ = { 364 | isa = XCConfigurationList; 365 | buildConfigurations = ( 366 | E36FB9542609BA45004272D9 /* Debug */, 367 | E36FB9552609BA45004272D9 /* Release */, 368 | ); 369 | defaultConfigurationIsVisible = 0; 370 | defaultConfigurationName = Release; 371 | }; 372 | E36FB9562609BA45004272D9 /* Build configuration list for PBXNativeTarget "KeyboardShortcutsExample" */ = { 373 | isa = XCConfigurationList; 374 | buildConfigurations = ( 375 | E36FB9572609BA45004272D9 /* Debug */, 376 | E36FB9582609BA45004272D9 /* Release */, 377 | ); 378 | defaultConfigurationIsVisible = 0; 379 | defaultConfigurationName = Release; 380 | }; 381 | /* End XCConfigurationList section */ 382 | 383 | /* Begin XCSwiftPackageProductDependency section */ 384 | E3A680502A042A0B00715D81 /* KeyboardShortcuts */ = { 385 | isa = XCSwiftPackageProductDependency; 386 | productName = KeyboardShortcuts; 387 | }; 388 | /* End XCSwiftPackageProductDependency section */ 389 | }; 390 | rootObject = E36FB93E2609BA43004272D9 /* Project object */; 391 | } 392 | -------------------------------------------------------------------------------- /Example/KeyboardShortcutsExample/App.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct AppMain: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | MainScreen() 8 | .task { 9 | AppState.shared.createMenus() 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Example/KeyboardShortcutsExample/AppState.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @MainActor 4 | final class AppState { 5 | static let shared = AppState() 6 | 7 | private init() {} 8 | 9 | func createMenus() { 10 | let testMenuItem = NSMenuItem() 11 | NSApp.mainMenu?.addItem(testMenuItem) 12 | 13 | let testMenu = NSMenu() 14 | testMenu.title = "Test" 15 | testMenuItem.submenu = testMenu 16 | 17 | testMenu.addCallbackItem("Shortcut 1") { [weak self] in 18 | self?.alert(1) 19 | } 20 | .setShortcut(for: .testShortcut1) 21 | 22 | testMenu.addCallbackItem("Shortcut 2") { [weak self] in 23 | self?.alert(2) 24 | } 25 | .setShortcut(for: .testShortcut2) 26 | 27 | testMenu.addCallbackItem("Shortcut 3") { [weak self] in 28 | self?.alert(3) 29 | } 30 | .setShortcut(for: .testShortcut3) 31 | 32 | testMenu.addCallbackItem("Shortcut 4") { [weak self] in 33 | self?.alert(4) 34 | } 35 | .setShortcut(for: .testShortcut4) 36 | } 37 | 38 | private func alert(_ number: Int) { 39 | let alert = NSAlert() 40 | alert.messageText = "Shortcut \(number) menu item action triggered!" 41 | alert.runModal() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Example/KeyboardShortcutsExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/KeyboardShortcutsExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Example/KeyboardShortcutsExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/KeyboardShortcutsExample/KeyboardShortcutsExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/KeyboardShortcutsExample/MainScreen.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import KeyboardShortcuts 3 | 4 | extension KeyboardShortcuts.Name { 5 | static let testShortcut1 = Self("testShortcut1") 6 | static let testShortcut2 = Self("testShortcut2") 7 | static let testShortcut3 = Self("testShortcut3") 8 | static let testShortcut4 = Self("testShortcut4") 9 | } 10 | 11 | private struct DynamicShortcutRecorder: View { 12 | @FocusState private var isFocused: Bool 13 | 14 | @Binding var name: KeyboardShortcuts.Name 15 | @Binding var isPressed: Bool 16 | 17 | var body: some View { 18 | HStack(alignment: .firstTextBaseline) { 19 | KeyboardShortcuts.Recorder(for: name) 20 | .focused($isFocused) 21 | .padding(.trailing, 10) 22 | Text("Pressed? \(isPressed ? "👍" : "👎")") 23 | .frame(width: 100, alignment: .leading) 24 | } 25 | .onChange(of: name) { 26 | isFocused = true 27 | } 28 | } 29 | } 30 | 31 | private struct DynamicShortcut: View { 32 | private struct Shortcut: Hashable, Identifiable { 33 | var id: String 34 | var name: KeyboardShortcuts.Name 35 | } 36 | 37 | private static let shortcuts = [ 38 | Shortcut(id: "Shortcut3", name: .testShortcut3), 39 | Shortcut(id: "Shortcut4", name: .testShortcut4) 40 | ] 41 | 42 | @State private var shortcut = Self.shortcuts.first! 43 | @State private var isPressed = false 44 | 45 | var body: some View { 46 | VStack { 47 | Text("Dynamic Recorder") 48 | .bold() 49 | .padding(.bottom, 10) 50 | VStack { 51 | Picker("Select shortcut:", selection: $shortcut) { 52 | ForEach(Self.shortcuts) { 53 | Text($0.id) 54 | .tag($0) 55 | } 56 | } 57 | Divider() 58 | DynamicShortcutRecorder(name: $shortcut.name, isPressed: $isPressed) 59 | } 60 | Divider() 61 | .padding(.vertical) 62 | Button("Reset All") { 63 | KeyboardShortcuts.resetAll() 64 | } 65 | } 66 | .frame(maxWidth: 300) 67 | .padding() 68 | .padding(.bottom, 20) 69 | .onChange(of: shortcut, initial: true) { oldValue, newValue in 70 | onShortcutChange(oldValue: oldValue, newValue: newValue) 71 | } 72 | } 73 | 74 | private func onShortcutChange(oldValue: Shortcut, newValue: Shortcut) { 75 | KeyboardShortcuts.disable(oldValue.name) 76 | 77 | KeyboardShortcuts.onKeyDown(for: newValue.name) { 78 | isPressed = true 79 | } 80 | 81 | KeyboardShortcuts.onKeyUp(for: newValue.name) { 82 | isPressed = false 83 | } 84 | } 85 | } 86 | 87 | private struct DoubleShortcut: View { 88 | @State private var isPressed1 = false 89 | @State private var isPressed2 = false 90 | 91 | var body: some View { 92 | Form { 93 | KeyboardShortcuts.Recorder("Shortcut 1:", name: .testShortcut1) 94 | .overlay(alignment: .trailing) { 95 | Text("Pressed? \(isPressed1 ? "👍" : "👎")") 96 | .offset(x: 90) 97 | } 98 | KeyboardShortcuts.Recorder(for: .testShortcut2) { 99 | Text("Shortcut 2:") // Intentionally using the verbose initializer for testing. 100 | } 101 | .overlay(alignment: .trailing) { 102 | Text("Pressed? \(isPressed2 ? "👍" : "👎")") 103 | .offset(x: 90) 104 | } 105 | Spacer() 106 | } 107 | .offset(x: -40) 108 | .frame(maxWidth: 300) 109 | .padding() 110 | .padding() 111 | .onGlobalKeyboardShortcut(.testShortcut1) { 112 | isPressed1 = $0 == .keyDown 113 | } 114 | .onGlobalKeyboardShortcut(.testShortcut2, type: .keyDown) { 115 | isPressed2 = true 116 | } 117 | .task { 118 | KeyboardShortcuts.onKeyUp(for: .testShortcut2) { 119 | isPressed2 = false 120 | } 121 | } 122 | } 123 | } 124 | 125 | struct MainScreen: View { 126 | var body: some View { 127 | VStack { 128 | DoubleShortcut() 129 | Divider() 130 | DynamicShortcut() 131 | } 132 | .frame(width: 400, height: 320) 133 | } 134 | } 135 | 136 | #Preview { 137 | MainScreen() 138 | } 139 | -------------------------------------------------------------------------------- /Example/KeyboardShortcutsExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/KeyboardShortcutsExample/Utilities.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @MainActor 4 | final class CallbackMenuItem: NSMenuItem { 5 | private static var validateCallback: ((NSMenuItem) -> Bool)? 6 | 7 | static func validate(_ callback: @escaping (NSMenuItem) -> Bool) { 8 | validateCallback = callback 9 | } 10 | 11 | private let callback: () -> Void 12 | 13 | init( 14 | _ title: String, 15 | key: String = "", 16 | keyModifiers: NSEvent.ModifierFlags? = nil, 17 | isEnabled: Bool = true, 18 | isChecked: Bool = false, 19 | isHidden: Bool = false, 20 | action: @escaping () -> Void 21 | ) { 22 | self.callback = action 23 | super.init(title: title, action: #selector(action(_:)), keyEquivalent: key) 24 | self.target = self 25 | self.isEnabled = isEnabled 26 | self.isChecked = isChecked 27 | self.isHidden = isHidden 28 | 29 | if let keyModifiers { 30 | self.keyEquivalentModifierMask = keyModifiers 31 | } 32 | } 33 | 34 | @available(*, unavailable) 35 | required init(coder decoder: NSCoder) { 36 | // swiftlint:disable:next fatal_error_message 37 | fatalError() 38 | } 39 | 40 | @objc 41 | private func action(_ sender: NSMenuItem) { 42 | callback() 43 | } 44 | } 45 | 46 | extension CallbackMenuItem: NSMenuItemValidation { 47 | func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { 48 | Self.validateCallback?(menuItem) ?? true 49 | } 50 | } 51 | 52 | extension NSMenuItem { 53 | convenience init( 54 | _ title: String, 55 | action: Selector? = nil, 56 | key: String = "", 57 | keyModifiers: NSEvent.ModifierFlags? = nil, 58 | data: Any? = nil, 59 | isEnabled: Bool = true, 60 | isChecked: Bool = false, 61 | isHidden: Bool = false 62 | ) { 63 | self.init(title: title, action: action, keyEquivalent: key) 64 | self.representedObject = data 65 | self.isEnabled = isEnabled 66 | self.isChecked = isChecked 67 | self.isHidden = isHidden 68 | 69 | if let keyModifiers { 70 | self.keyEquivalentModifierMask = keyModifiers 71 | } 72 | } 73 | 74 | var isChecked: Bool { 75 | get { state == .on } 76 | set { 77 | state = newValue ? .on : .off 78 | } 79 | } 80 | } 81 | 82 | extension NSMenu { 83 | @MainActor 84 | @discardableResult 85 | func addCallbackItem( 86 | _ title: String, 87 | key: String = "", 88 | keyModifiers: NSEvent.ModifierFlags? = nil, 89 | isEnabled: Bool = true, 90 | isChecked: Bool = false, 91 | isHidden: Bool = false, 92 | action: @escaping () -> Void 93 | ) -> NSMenuItem { 94 | let menuItem = CallbackMenuItem( 95 | title, 96 | key: key, 97 | keyModifiers: keyModifiers, 98 | isEnabled: isEnabled, 99 | isChecked: isChecked, 100 | isHidden: isHidden, 101 | action: action 102 | ) 103 | addItem(menuItem) 104 | return menuItem 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.11 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "KeyboardShortcuts", 6 | defaultLocalization: "en", 7 | platforms: [ 8 | .macOS(.v10_15) 9 | ], 10 | products: [ 11 | .library( 12 | name: "KeyboardShortcuts", 13 | targets: [ 14 | "KeyboardShortcuts" 15 | ] 16 | ) 17 | ], 18 | targets: [ 19 | .target( 20 | name: "KeyboardShortcuts" 21 | ), 22 | .testTarget( 23 | name: "KeyboardShortcutsTests", 24 | dependencies: [ 25 | "KeyboardShortcuts" 26 | ] 27 | ) 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/CarbonKeyboardShortcuts.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import Carbon.HIToolbox 3 | 4 | private func carbonKeyboardShortcutsEventHandler(eventHandlerCall: EventHandlerCallRef?, event: EventRef?, userData: UnsafeMutableRawPointer?) -> OSStatus { 5 | CarbonKeyboardShortcuts.handleEvent(event) 6 | } 7 | 8 | enum CarbonKeyboardShortcuts { 9 | private final class HotKey { 10 | let shortcut: KeyboardShortcuts.Shortcut 11 | let carbonHotKeyId: Int 12 | var carbonHotKey: EventHotKeyRef? 13 | let onKeyDown: (KeyboardShortcuts.Shortcut) -> Void 14 | let onKeyUp: (KeyboardShortcuts.Shortcut) -> Void 15 | 16 | init( 17 | shortcut: KeyboardShortcuts.Shortcut, 18 | carbonHotKeyID: Int, 19 | carbonHotKey: EventHotKeyRef, 20 | onKeyDown: @escaping (KeyboardShortcuts.Shortcut) -> Void, 21 | onKeyUp: @escaping (KeyboardShortcuts.Shortcut) -> Void 22 | ) { 23 | self.shortcut = shortcut 24 | self.carbonHotKeyId = carbonHotKeyID 25 | self.carbonHotKey = carbonHotKey 26 | self.onKeyDown = onKeyDown 27 | self.onKeyUp = onKeyUp 28 | } 29 | } 30 | 31 | private static var hotKeys = [Int: HotKey]() 32 | 33 | // `SSKS` is just short for `Sindre Sorhus Keyboard Shortcuts`. 34 | // Using an integer now that `UTGetOSTypeFromString("SSKS" as CFString)` is deprecated. 35 | // swiftlint:disable:next number_separator 36 | private static let hotKeySignature: UInt32 = 1397967699 // OSType => "SSKS" 37 | 38 | private static var hotKeyId = 0 39 | private static var eventHandler: EventHandlerRef? 40 | 41 | private static let hotKeyEventTypes = [ 42 | EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), 43 | EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)) 44 | ] 45 | private static let rawKeyEventTypes = [ 46 | EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventRawKeyDown)), 47 | EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventRawKeyUp)) 48 | ] 49 | 50 | private static let keyEventMonitor = RunLoopLocalEventMonitor(events: [.keyDown, .keyUp], runLoopMode: .eventTracking) { event in 51 | guard 52 | let eventRef = OpaquePointer(event.eventRef), 53 | handleRawKeyEvent(eventRef) == noErr 54 | else { 55 | return event 56 | } 57 | 58 | return nil 59 | } 60 | 61 | private static func setUpEventHandlerIfNeeded() { 62 | guard 63 | eventHandler == nil, 64 | let dispatcher = GetEventDispatcherTarget() 65 | else { 66 | return 67 | } 68 | 69 | var handler: EventHandlerRef? 70 | let error = InstallEventHandler( 71 | dispatcher, 72 | carbonKeyboardShortcutsEventHandler, 73 | 0, 74 | nil, 75 | nil, 76 | &handler 77 | ) 78 | 79 | guard 80 | error == noErr, 81 | let handler 82 | else { 83 | return 84 | } 85 | 86 | eventHandler = handler 87 | 88 | updateEventHandler() 89 | } 90 | 91 | static func updateEventHandler() { 92 | guard eventHandler != nil else { 93 | return 94 | } 95 | 96 | if KeyboardShortcuts.isEnabled { 97 | if KeyboardShortcuts.isMenuOpen { 98 | softUnregisterAll() 99 | RemoveEventTypesFromHandler(eventHandler, hotKeyEventTypes.count, hotKeyEventTypes) 100 | 101 | if #available(macOS 14, *) { 102 | keyEventMonitor.start() 103 | } else { 104 | AddEventTypesToHandler(eventHandler, rawKeyEventTypes.count, rawKeyEventTypes) 105 | } 106 | } else { 107 | softRegisterAll() 108 | 109 | if #available(macOS 14, *) { 110 | keyEventMonitor.stop() 111 | } else { 112 | RemoveEventTypesFromHandler(eventHandler, rawKeyEventTypes.count, rawKeyEventTypes) 113 | } 114 | 115 | AddEventTypesToHandler(eventHandler, hotKeyEventTypes.count, hotKeyEventTypes) 116 | } 117 | } else { 118 | softUnregisterAll() 119 | RemoveEventTypesFromHandler(eventHandler, hotKeyEventTypes.count, hotKeyEventTypes) 120 | 121 | if #available(macOS 14, *) { 122 | keyEventMonitor.stop() 123 | } else { 124 | RemoveEventTypesFromHandler(eventHandler, rawKeyEventTypes.count, rawKeyEventTypes) 125 | } 126 | } 127 | } 128 | 129 | static func register( 130 | _ shortcut: KeyboardShortcuts.Shortcut, 131 | onKeyDown: @escaping (KeyboardShortcuts.Shortcut) -> Void, 132 | onKeyUp: @escaping (KeyboardShortcuts.Shortcut) -> Void 133 | ) { 134 | hotKeyId += 1 135 | 136 | var eventHotKey: EventHotKeyRef? 137 | let registerError = RegisterEventHotKey( 138 | UInt32(shortcut.carbonKeyCode), 139 | UInt32(shortcut.carbonModifiers), 140 | EventHotKeyID(signature: hotKeySignature, id: UInt32(hotKeyId)), 141 | GetEventDispatcherTarget(), 142 | 0, 143 | &eventHotKey 144 | ) 145 | 146 | guard 147 | registerError == noErr, 148 | let carbonHotKey = eventHotKey 149 | else { 150 | print("Error registering hotkey \(shortcut):", registerError) 151 | return 152 | } 153 | 154 | hotKeys[hotKeyId] = HotKey( 155 | shortcut: shortcut, 156 | carbonHotKeyID: hotKeyId, 157 | carbonHotKey: carbonHotKey, 158 | onKeyDown: onKeyDown, 159 | onKeyUp: onKeyUp 160 | ) 161 | 162 | setUpEventHandlerIfNeeded() 163 | } 164 | 165 | private static func softRegisterAll() { 166 | for hotKey in hotKeys.values { 167 | guard hotKey.carbonHotKey == nil else { 168 | continue 169 | } 170 | 171 | var eventHotKey: EventHotKeyRef? 172 | let error = RegisterEventHotKey( 173 | UInt32(hotKey.shortcut.carbonKeyCode), 174 | UInt32(hotKey.shortcut.carbonModifiers), 175 | EventHotKeyID(signature: hotKeySignature, id: UInt32(hotKey.carbonHotKeyId)), 176 | GetEventDispatcherTarget(), 177 | 0, 178 | &eventHotKey 179 | ) 180 | 181 | guard 182 | error == noErr, 183 | let eventHotKey 184 | else { 185 | print("Error registering hotkey \(hotKey.shortcut):", error) 186 | hotKeys.removeValue(forKey: hotKey.carbonHotKeyId) 187 | continue 188 | } 189 | 190 | hotKey.carbonHotKey = eventHotKey 191 | } 192 | } 193 | 194 | private static func unregisterHotKey(_ hotKey: HotKey) { 195 | UnregisterEventHotKey(hotKey.carbonHotKey) 196 | hotKeys.removeValue(forKey: hotKey.carbonHotKeyId) 197 | } 198 | 199 | static func unregister(_ shortcut: KeyboardShortcuts.Shortcut) { 200 | for hotKey in hotKeys.values where hotKey.shortcut == shortcut { 201 | unregisterHotKey(hotKey) 202 | } 203 | } 204 | 205 | static func unregisterAll() { 206 | for hotKey in hotKeys.values { 207 | unregisterHotKey(hotKey) 208 | } 209 | } 210 | 211 | private static func softUnregisterAll() { 212 | for hotKey in hotKeys.values { 213 | UnregisterEventHotKey(hotKey.carbonHotKey) 214 | hotKey.carbonHotKey = nil 215 | } 216 | } 217 | 218 | fileprivate static func handleEvent(_ event: EventRef?) -> OSStatus { 219 | guard let event else { 220 | return OSStatus(eventNotHandledErr) 221 | } 222 | 223 | switch Int(GetEventKind(event)) { 224 | case kEventHotKeyPressed, kEventHotKeyReleased: 225 | return handleHotKeyEvent(event) 226 | case kEventRawKeyDown, kEventRawKeyUp: 227 | return handleRawKeyEvent(event) 228 | default: 229 | break 230 | } 231 | 232 | return OSStatus(eventNotHandledErr) 233 | } 234 | 235 | private static func handleHotKeyEvent(_ event: EventRef) -> OSStatus { 236 | var eventHotKeyId = EventHotKeyID() 237 | let error = GetEventParameter( 238 | event, 239 | UInt32(kEventParamDirectObject), 240 | UInt32(typeEventHotKeyID), 241 | nil, 242 | MemoryLayout.size, 243 | nil, 244 | &eventHotKeyId 245 | ) 246 | 247 | guard error == noErr else { 248 | return error 249 | } 250 | 251 | guard 252 | eventHotKeyId.signature == hotKeySignature, 253 | let hotKey = hotKeys[Int(eventHotKeyId.id)] 254 | else { 255 | return OSStatus(eventNotHandledErr) 256 | } 257 | 258 | switch Int(GetEventKind(event)) { 259 | case kEventHotKeyPressed: 260 | hotKey.onKeyDown(hotKey.shortcut) 261 | return noErr 262 | case kEventHotKeyReleased: 263 | hotKey.onKeyUp(hotKey.shortcut) 264 | return noErr 265 | default: 266 | break 267 | } 268 | 269 | return OSStatus(eventNotHandledErr) 270 | } 271 | 272 | private static func handleRawKeyEvent(_ event: EventRef) -> OSStatus { 273 | var eventKeyCode = UInt32() 274 | let keyCodeError = GetEventParameter( 275 | event, 276 | UInt32(kEventParamKeyCode), 277 | typeUInt32, 278 | nil, 279 | MemoryLayout.size, 280 | nil, 281 | &eventKeyCode 282 | ) 283 | 284 | guard keyCodeError == noErr else { 285 | return keyCodeError 286 | } 287 | 288 | var eventKeyModifiers = UInt32() 289 | let keyModifiersError = GetEventParameter( 290 | event, 291 | UInt32(kEventParamKeyModifiers), 292 | typeUInt32, 293 | nil, 294 | MemoryLayout.size, 295 | nil, 296 | &eventKeyModifiers 297 | ) 298 | 299 | guard keyModifiersError == noErr else { 300 | return keyModifiersError 301 | } 302 | 303 | let shortcut = KeyboardShortcuts.Shortcut(carbonKeyCode: Int(eventKeyCode), carbonModifiers: Int(eventKeyModifiers)) 304 | 305 | guard let hotKey = (hotKeys.values.first { $0.shortcut == shortcut }) else { 306 | return OSStatus(eventNotHandledErr) 307 | } 308 | 309 | switch Int(GetEventKind(event)) { 310 | case kEventRawKeyDown: 311 | hotKey.onKeyDown(hotKey.shortcut) 312 | return noErr 313 | case kEventRawKeyUp: 314 | hotKey.onKeyUp(hotKey.shortcut) 315 | return noErr 316 | default: 317 | break 318 | } 319 | 320 | return OSStatus(eventNotHandledErr) 321 | } 322 | } 323 | 324 | extension CarbonKeyboardShortcuts { 325 | static var system: [KeyboardShortcuts.Shortcut] { 326 | var shortcutsUnmanaged: Unmanaged? 327 | guard 328 | CopySymbolicHotKeys(&shortcutsUnmanaged) == noErr, 329 | let shortcuts = shortcutsUnmanaged?.takeRetainedValue() as? [[String: Any]] 330 | else { 331 | assertionFailure("Could not get system keyboard shortcuts") 332 | return [] 333 | } 334 | 335 | return shortcuts.compactMap { 336 | guard 337 | ($0[kHISymbolicHotKeyEnabled] as? Bool) == true, 338 | let carbonKeyCode = $0[kHISymbolicHotKeyCode] as? Int, 339 | let carbonModifiers = $0[kHISymbolicHotKeyModifiers] as? Int 340 | else { 341 | return nil 342 | } 343 | 344 | return KeyboardShortcuts.Shortcut( 345 | carbonKeyCode: carbonKeyCode, 346 | carbonModifiers: carbonModifiers 347 | ) 348 | } 349 | } 350 | } 351 | #endif 352 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Key.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import Carbon.HIToolbox 3 | 4 | extension KeyboardShortcuts { 5 | // swiftlint:disable identifier_name 6 | /** 7 | Represents a key on the keyboard. 8 | */ 9 | public struct Key: Hashable, RawRepresentable, Sendable { 10 | // MARK: Letters 11 | 12 | public static let a = Self(kVK_ANSI_A) 13 | public static let b = Self(kVK_ANSI_B) 14 | public static let c = Self(kVK_ANSI_C) 15 | public static let d = Self(kVK_ANSI_D) 16 | public static let e = Self(kVK_ANSI_E) 17 | public static let f = Self(kVK_ANSI_F) 18 | public static let g = Self(kVK_ANSI_G) 19 | public static let h = Self(kVK_ANSI_H) 20 | public static let i = Self(kVK_ANSI_I) 21 | public static let j = Self(kVK_ANSI_J) 22 | public static let k = Self(kVK_ANSI_K) 23 | public static let l = Self(kVK_ANSI_L) 24 | public static let m = Self(kVK_ANSI_M) 25 | public static let n = Self(kVK_ANSI_N) 26 | public static let o = Self(kVK_ANSI_O) 27 | public static let p = Self(kVK_ANSI_P) 28 | public static let q = Self(kVK_ANSI_Q) 29 | public static let r = Self(kVK_ANSI_R) 30 | public static let s = Self(kVK_ANSI_S) 31 | public static let t = Self(kVK_ANSI_T) 32 | public static let u = Self(kVK_ANSI_U) 33 | public static let v = Self(kVK_ANSI_V) 34 | public static let w = Self(kVK_ANSI_W) 35 | public static let x = Self(kVK_ANSI_X) 36 | public static let y = Self(kVK_ANSI_Y) 37 | public static let z = Self(kVK_ANSI_Z) 38 | // swiftlint:enable identifier_name 39 | 40 | // MARK: Numbers 41 | 42 | public static let zero = Self(kVK_ANSI_0) 43 | public static let one = Self(kVK_ANSI_1) 44 | public static let two = Self(kVK_ANSI_2) 45 | public static let three = Self(kVK_ANSI_3) 46 | public static let four = Self(kVK_ANSI_4) 47 | public static let five = Self(kVK_ANSI_5) 48 | public static let six = Self(kVK_ANSI_6) 49 | public static let seven = Self(kVK_ANSI_7) 50 | public static let eight = Self(kVK_ANSI_8) 51 | public static let nine = Self(kVK_ANSI_9) 52 | 53 | // MARK: Modifiers 54 | 55 | public static let capsLock = Self(kVK_CapsLock) 56 | public static let shift = Self(kVK_Shift) 57 | public static let function = Self(kVK_Function) 58 | public static let control = Self(kVK_Control) 59 | public static let option = Self(kVK_Option) 60 | public static let command = Self(kVK_Command) 61 | public static let rightCommand = Self(kVK_RightCommand) 62 | public static let rightOption = Self(kVK_RightOption) 63 | public static let rightControl = Self(kVK_RightControl) 64 | public static let rightShift = Self(kVK_RightShift) 65 | 66 | // MARK: Miscellaneous 67 | 68 | public static let `return` = Self(kVK_Return) 69 | public static let backslash = Self(kVK_ANSI_Backslash) 70 | public static let backtick = Self(kVK_ANSI_Grave) 71 | public static let comma = Self(kVK_ANSI_Comma) 72 | public static let equal = Self(kVK_ANSI_Equal) 73 | public static let minus = Self(kVK_ANSI_Minus) 74 | public static let period = Self(kVK_ANSI_Period) 75 | public static let quote = Self(kVK_ANSI_Quote) 76 | public static let semicolon = Self(kVK_ANSI_Semicolon) 77 | public static let slash = Self(kVK_ANSI_Slash) 78 | public static let space = Self(kVK_Space) 79 | public static let tab = Self(kVK_Tab) 80 | public static let leftBracket = Self(kVK_ANSI_LeftBracket) 81 | public static let rightBracket = Self(kVK_ANSI_RightBracket) 82 | public static let pageUp = Self(kVK_PageUp) 83 | public static let pageDown = Self(kVK_PageDown) 84 | public static let home = Self(kVK_Home) 85 | public static let end = Self(kVK_End) 86 | public static let upArrow = Self(kVK_UpArrow) 87 | public static let rightArrow = Self(kVK_RightArrow) 88 | public static let downArrow = Self(kVK_DownArrow) 89 | public static let leftArrow = Self(kVK_LeftArrow) 90 | public static let escape = Self(kVK_Escape) 91 | public static let delete = Self(kVK_Delete) 92 | public static let deleteForward = Self(kVK_ForwardDelete) 93 | public static let help = Self(kVK_Help) 94 | public static let mute = Self(kVK_Mute) 95 | public static let volumeUp = Self(kVK_VolumeUp) 96 | public static let volumeDown = Self(kVK_VolumeDown) 97 | 98 | // MARK: Function 99 | 100 | public static let f1 = Self(kVK_F1) 101 | public static let f2 = Self(kVK_F2) 102 | public static let f3 = Self(kVK_F3) 103 | public static let f4 = Self(kVK_F4) 104 | public static let f5 = Self(kVK_F5) 105 | public static let f6 = Self(kVK_F6) 106 | public static let f7 = Self(kVK_F7) 107 | public static let f8 = Self(kVK_F8) 108 | public static let f9 = Self(kVK_F9) 109 | public static let f10 = Self(kVK_F10) 110 | public static let f11 = Self(kVK_F11) 111 | public static let f12 = Self(kVK_F12) 112 | public static let f13 = Self(kVK_F13) 113 | public static let f14 = Self(kVK_F14) 114 | public static let f15 = Self(kVK_F15) 115 | public static let f16 = Self(kVK_F16) 116 | public static let f17 = Self(kVK_F17) 117 | public static let f18 = Self(kVK_F18) 118 | public static let f19 = Self(kVK_F19) 119 | public static let f20 = Self(kVK_F20) 120 | 121 | // MARK: Keypad 122 | 123 | public static let keypad0 = Self(kVK_ANSI_Keypad0) 124 | public static let keypad1 = Self(kVK_ANSI_Keypad1) 125 | public static let keypad2 = Self(kVK_ANSI_Keypad2) 126 | public static let keypad3 = Self(kVK_ANSI_Keypad3) 127 | public static let keypad4 = Self(kVK_ANSI_Keypad4) 128 | public static let keypad5 = Self(kVK_ANSI_Keypad5) 129 | public static let keypad6 = Self(kVK_ANSI_Keypad6) 130 | public static let keypad7 = Self(kVK_ANSI_Keypad7) 131 | public static let keypad8 = Self(kVK_ANSI_Keypad8) 132 | public static let keypad9 = Self(kVK_ANSI_Keypad9) 133 | public static let keypadClear = Self(kVK_ANSI_KeypadClear) 134 | public static let keypadDecimal = Self(kVK_ANSI_KeypadDecimal) 135 | public static let keypadDivide = Self(kVK_ANSI_KeypadDivide) 136 | public static let keypadEnter = Self(kVK_ANSI_KeypadEnter) 137 | public static let keypadEquals = Self(kVK_ANSI_KeypadEquals) 138 | public static let keypadMinus = Self(kVK_ANSI_KeypadMinus) 139 | public static let keypadMultiply = Self(kVK_ANSI_KeypadMultiply) 140 | public static let keypadPlus = Self(kVK_ANSI_KeypadPlus) 141 | 142 | // MARK: Properties 143 | 144 | /** 145 | The raw key code. 146 | */ 147 | public let rawValue: Int 148 | 149 | // MARK: Initializers 150 | 151 | /** 152 | Create a `Key` from a key code. 153 | */ 154 | public init(rawValue: Int) { 155 | self.rawValue = rawValue 156 | } 157 | 158 | private init(_ value: Int) { 159 | self.init(rawValue: value) 160 | } 161 | } 162 | } 163 | 164 | extension KeyboardShortcuts.Key { 165 | /** 166 | All the function keys. 167 | */ 168 | static let functionKeys: Set = [ 169 | .f1, 170 | .f2, 171 | .f3, 172 | .f4, 173 | .f5, 174 | .f6, 175 | .f7, 176 | .f8, 177 | .f9, 178 | .f10, 179 | .f11, 180 | .f12, 181 | .f13, 182 | .f14, 183 | .f15, 184 | .f16, 185 | .f17, 186 | .f18, 187 | .f19, 188 | .f20 189 | ] 190 | 191 | /** 192 | Returns true if the key is a function key. For example, `F1`. 193 | */ 194 | var isFunctionKey: Bool { Self.functionKeys.contains(self) } 195 | } 196 | #endif 197 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/KeyboardShortcuts.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import AppKit.NSMenu 3 | 4 | /** 5 | Global keyboard shortcuts for your macOS app. 6 | */ 7 | public enum KeyboardShortcuts { 8 | private static var registeredShortcuts = Set() 9 | 10 | private static var legacyKeyDownHandlers = [Name: [() -> Void]]() 11 | private static var legacyKeyUpHandlers = [Name: [() -> Void]]() 12 | 13 | private static var streamKeyDownHandlers = [Name: [UUID: () -> Void]]() 14 | private static var streamKeyUpHandlers = [Name: [UUID: () -> Void]]() 15 | 16 | private static var shortcutsForLegacyHandlers: Set { 17 | let shortcuts = [legacyKeyDownHandlers.keys, legacyKeyUpHandlers.keys] 18 | .flatMap { $0 } 19 | .compactMap(\.shortcut) 20 | 21 | return Set(shortcuts) 22 | } 23 | 24 | private static var shortcutsForStreamHandlers: Set { 25 | let shortcuts = [streamKeyDownHandlers.keys, streamKeyUpHandlers.keys] 26 | .flatMap { $0 } 27 | .compactMap(\.shortcut) 28 | 29 | return Set(shortcuts) 30 | } 31 | 32 | private static var shortcutsForHandlers: Set { 33 | shortcutsForLegacyHandlers.union(shortcutsForStreamHandlers) 34 | } 35 | 36 | private static var isInitialized = false 37 | 38 | private static var openMenuObserver: NSObjectProtocol? 39 | private static var closeMenuObserver: NSObjectProtocol? 40 | 41 | /** 42 | When `true`, event handlers will not be called for registered keyboard shortcuts. 43 | */ 44 | static var isPaused = false 45 | 46 | /** 47 | Enable/disable monitoring of all keyboard shortcuts. 48 | 49 | The default is `true`. 50 | */ 51 | public static var isEnabled = true { 52 | didSet { 53 | guard isEnabled != oldValue else { 54 | return 55 | } 56 | 57 | CarbonKeyboardShortcuts.updateEventHandler() 58 | } 59 | } 60 | 61 | static var allNames: Set { 62 | UserDefaults.standard.dictionaryRepresentation() 63 | .compactMap { key, _ in 64 | guard key.hasPrefix(userDefaultsPrefix) else { 65 | return nil 66 | } 67 | 68 | let name = key.replacingPrefix(userDefaultsPrefix, with: "") 69 | return .init(name) 70 | } 71 | .toSet() 72 | } 73 | 74 | /** 75 | Enable keyboard shortcuts to work even when an `NSMenu` is open by setting this property when the menu opens and closes. 76 | 77 | `NSMenu` runs in a tracking run mode that blocks keyboard shortcuts events. When you set this property to `true`, it switches to a different kind of event handler, which does work when the menu is open. 78 | 79 | The main use-case for this is toggling the menu of a menu bar app with a keyboard shortcut. 80 | */ 81 | private(set) static var isMenuOpen = false { 82 | didSet { 83 | guard isMenuOpen != oldValue else { 84 | return 85 | } 86 | 87 | CarbonKeyboardShortcuts.updateEventHandler() 88 | } 89 | } 90 | 91 | private static func register(_ shortcut: Shortcut) { 92 | guard !registeredShortcuts.contains(shortcut) else { 93 | return 94 | } 95 | 96 | CarbonKeyboardShortcuts.register( 97 | shortcut, 98 | onKeyDown: handleOnKeyDown, 99 | onKeyUp: handleOnKeyUp 100 | ) 101 | 102 | registeredShortcuts.insert(shortcut) 103 | } 104 | 105 | /** 106 | Register the shortcut for the given name if it has a shortcut. 107 | */ 108 | private static func registerShortcutIfNeeded(for name: Name) { 109 | guard let shortcut = getShortcut(for: name) else { 110 | return 111 | } 112 | 113 | register(shortcut) 114 | } 115 | 116 | private static func unregister(_ shortcut: Shortcut) { 117 | CarbonKeyboardShortcuts.unregister(shortcut) 118 | registeredShortcuts.remove(shortcut) 119 | } 120 | 121 | /** 122 | Unregister the given shortcut if it has no handlers. 123 | */ 124 | private static func unregisterIfNeeded(_ shortcut: Shortcut) { 125 | guard !shortcutsForHandlers.contains(shortcut) else { 126 | return 127 | } 128 | 129 | unregister(shortcut) 130 | } 131 | 132 | /** 133 | Unregister the shortcut for the given name if it has no handlers. 134 | */ 135 | private static func unregisterShortcutIfNeeded(for name: Name) { 136 | guard let shortcut = name.shortcut else { 137 | return 138 | } 139 | 140 | unregisterIfNeeded(shortcut) 141 | } 142 | 143 | private static func unregisterAll() { 144 | CarbonKeyboardShortcuts.unregisterAll() 145 | registeredShortcuts.removeAll() 146 | 147 | // TODO: Should remove user defaults too. 148 | } 149 | 150 | static func initialize() { 151 | guard !isInitialized else { 152 | return 153 | } 154 | 155 | openMenuObserver = NotificationCenter.default.addObserver(forName: NSMenu.didBeginTrackingNotification, object: nil, queue: nil) { _ in 156 | isMenuOpen = true 157 | } 158 | 159 | closeMenuObserver = NotificationCenter.default.addObserver(forName: NSMenu.didEndTrackingNotification, object: nil, queue: nil) { _ in 160 | isMenuOpen = false 161 | } 162 | 163 | isInitialized = true 164 | } 165 | 166 | /** 167 | Remove all handlers receiving keyboard shortcuts events. 168 | 169 | This can be used to reset the handlers before re-creating them to avoid having multiple handlers for the same shortcut. 170 | 171 | - Note: This method does not affect listeners using ``events(for:)``. 172 | */ 173 | public static func removeAllHandlers() { 174 | let shortcutsToUnregister = shortcutsForLegacyHandlers.subtracting(shortcutsForStreamHandlers) 175 | 176 | for shortcut in shortcutsToUnregister { 177 | unregister(shortcut) 178 | } 179 | 180 | legacyKeyDownHandlers = [:] 181 | legacyKeyUpHandlers = [:] 182 | } 183 | 184 | /** 185 | Remove the keyboard shortcut handler for the given name. 186 | 187 | This can be used to reset the handler before re-creating it to avoid having multiple handlers for the same shortcut. 188 | 189 | - Parameter name: The name of the keyboard shortcut to remove handlers for. 190 | 191 | - Note: This method does not affect listeners using ``events(for:)``. 192 | */ 193 | public static func removeHandler(for name: Name) { 194 | legacyKeyDownHandlers[name] = nil 195 | legacyKeyUpHandlers[name] = nil 196 | 197 | // Make sure not to unregister stream handlers. 198 | guard 199 | let shortcut = getShortcut(for: name), 200 | !shortcutsForStreamHandlers.contains(shortcut) 201 | else { 202 | return 203 | } 204 | 205 | unregister(shortcut) 206 | } 207 | 208 | /** 209 | Returns whether the keyboard shortcut for the given name is enabled. 210 | 211 | This checks if the shortcut is registered and will trigger handlers. It respects the global ``isEnabled``. 212 | 213 | ```swift 214 | let isEnabled = KeyboardShortcuts.isEnabled(for: .toggleUnicornMode) 215 | ``` 216 | 217 | - Tip: Use ``disable(_:)-(Name...)`` and ``enable(_:)-(Name...)`` to change the status. 218 | */ 219 | public static func isEnabled(for name: Name) -> Bool { 220 | guard 221 | isEnabled, 222 | let shortcut = getShortcut(for: name) 223 | else { 224 | return false 225 | } 226 | 227 | return registeredShortcuts.contains(shortcut) 228 | } 229 | 230 | /** 231 | Disable the keyboard shortcut for one or more names. 232 | */ 233 | public static func disable(_ names: [Name]) { 234 | for name in names { 235 | guard let shortcut = getShortcut(for: name) else { 236 | continue 237 | } 238 | 239 | unregister(shortcut) 240 | } 241 | } 242 | 243 | /** 244 | Disable the keyboard shortcut for one or more names. 245 | */ 246 | public static func disable(_ names: Name...) { 247 | disable(names) 248 | } 249 | 250 | /** 251 | Enable the keyboard shortcut for one or more names. 252 | */ 253 | public static func enable(_ names: [Name]) { 254 | for name in names { 255 | guard let shortcut = getShortcut(for: name) else { 256 | continue 257 | } 258 | 259 | register(shortcut) 260 | } 261 | } 262 | 263 | /** 264 | Enable the keyboard shortcut for one or more names. 265 | */ 266 | public static func enable(_ names: Name...) { 267 | enable(names) 268 | } 269 | 270 | /** 271 | Reset the keyboard shortcut for one or more names. 272 | 273 | If the `Name` has a default shortcut, it will reset to that. 274 | 275 | - Note: This overload exists as Swift doesn't support splatting. 276 | 277 | ```swift 278 | import SwiftUI 279 | import KeyboardShortcuts 280 | 281 | struct SettingsScreen: View { 282 | var body: some View { 283 | VStack { 284 | // … 285 | Button("Reset") { 286 | KeyboardShortcuts.reset(.toggleUnicornMode) 287 | } 288 | } 289 | } 290 | } 291 | ``` 292 | */ 293 | public static func reset(_ names: [Name]) { 294 | for name in names { 295 | setShortcut(name.defaultShortcut, for: name) 296 | } 297 | } 298 | 299 | /** 300 | Reset the keyboard shortcut for one or more names. 301 | 302 | If the `Name` has a default shortcut, it will reset to that. 303 | 304 | ```swift 305 | import SwiftUI 306 | import KeyboardShortcuts 307 | 308 | struct SettingsScreen: View { 309 | var body: some View { 310 | VStack { 311 | // … 312 | Button("Reset") { 313 | KeyboardShortcuts.reset(.toggleUnicornMode) 314 | } 315 | } 316 | } 317 | } 318 | ``` 319 | */ 320 | public static func reset(_ names: Name...) { 321 | reset(names) 322 | } 323 | 324 | /** 325 | Reset the keyboard shortcut for all the names. 326 | 327 | Unlike `reset(…)`, this resets all the shortcuts to `nil`, not the `defaultValue`. 328 | 329 | ```swift 330 | import SwiftUI 331 | import KeyboardShortcuts 332 | 333 | struct SettingsScreen: View { 334 | var body: some View { 335 | VStack { 336 | // … 337 | Button("Reset All") { 338 | KeyboardShortcuts.resetAll() 339 | } 340 | } 341 | } 342 | } 343 | ``` 344 | */ 345 | public static func resetAll() { 346 | reset(allNames.toArray()) 347 | } 348 | 349 | /** 350 | Set the keyboard shortcut for a name. 351 | 352 | Setting it to `nil` removes the shortcut, even if the `Name` has a default shortcut defined. Use `.reset()` if you want it to respect the default shortcut. 353 | 354 | You would usually not need this as the user would be the one setting the shortcut in a settings user-interface, but it can be useful when, for example, migrating from a different keyboard shortcuts package. 355 | */ 356 | public static func setShortcut(_ shortcut: Shortcut?, for name: Name) { 357 | if let shortcut { 358 | userDefaultsSet(name: name, shortcut: shortcut) 359 | } else { 360 | if name.defaultShortcut != nil { 361 | userDefaultsDisable(name: name) 362 | } else { 363 | userDefaultsRemove(name: name) 364 | } 365 | } 366 | } 367 | 368 | /** 369 | Get the keyboard shortcut for a name. 370 | */ 371 | public static func getShortcut(for name: Name) -> Shortcut? { 372 | guard 373 | let data = UserDefaults.standard.string(forKey: userDefaultsKey(for: name))?.data(using: .utf8), 374 | let decoded = try? JSONDecoder().decode(Shortcut.self, from: data) 375 | else { 376 | return nil 377 | } 378 | 379 | return decoded 380 | } 381 | 382 | private static func handleOnKeyDown(_ shortcut: Shortcut) { 383 | guard !isPaused else { 384 | return 385 | } 386 | 387 | for (name, handlers) in legacyKeyDownHandlers { 388 | guard getShortcut(for: name) == shortcut else { 389 | continue 390 | } 391 | 392 | for handler in handlers { 393 | handler() 394 | } 395 | } 396 | 397 | for (name, handlers) in streamKeyDownHandlers { 398 | guard getShortcut(for: name) == shortcut else { 399 | continue 400 | } 401 | 402 | for handler in handlers.values { 403 | handler() 404 | } 405 | } 406 | } 407 | 408 | private static func handleOnKeyUp(_ shortcut: Shortcut) { 409 | guard !isPaused else { 410 | return 411 | } 412 | 413 | for (name, handlers) in legacyKeyUpHandlers { 414 | guard getShortcut(for: name) == shortcut else { 415 | continue 416 | } 417 | 418 | for handler in handlers { 419 | handler() 420 | } 421 | } 422 | 423 | for (name, handlers) in streamKeyUpHandlers { 424 | guard getShortcut(for: name) == shortcut else { 425 | continue 426 | } 427 | 428 | for handler in handlers.values { 429 | handler() 430 | } 431 | } 432 | } 433 | 434 | /** 435 | Listen to the keyboard shortcut with the given name being pressed. 436 | 437 | You can register multiple listeners. 438 | 439 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do. 440 | 441 | - Important: This will be deprecated in the future. Prefer ``events(for:)`` for new code. 442 | 443 | ```swift 444 | import AppKit 445 | import KeyboardShortcuts 446 | 447 | @main 448 | final class AppDelegate: NSObject, NSApplicationDelegate { 449 | func applicationDidFinishLaunching(_ notification: Notification) { 450 | KeyboardShortcuts.onKeyDown(for: .toggleUnicornMode) { [self] in 451 | isUnicornMode.toggle() 452 | } 453 | } 454 | } 455 | ``` 456 | */ 457 | public static func onKeyDown(for name: Name, action: @escaping () -> Void) { 458 | legacyKeyDownHandlers[name, default: []].append(action) 459 | registerShortcutIfNeeded(for: name) 460 | } 461 | 462 | /** 463 | Listen to the keyboard shortcut with the given name being pressed. 464 | 465 | You can register multiple listeners. 466 | 467 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do. 468 | 469 | - Important: This will be deprecated in the future. Prefer ``events(for:)`` for new code. 470 | 471 | ```swift 472 | import AppKit 473 | import KeyboardShortcuts 474 | 475 | @main 476 | final class AppDelegate: NSObject, NSApplicationDelegate { 477 | func applicationDidFinishLaunching(_ notification: Notification) { 478 | KeyboardShortcuts.onKeyUp(for: .toggleUnicornMode) { [self] in 479 | isUnicornMode.toggle() 480 | } 481 | } 482 | } 483 | ``` 484 | */ 485 | public static func onKeyUp(for name: Name, action: @escaping () -> Void) { 486 | legacyKeyUpHandlers[name, default: []].append(action) 487 | registerShortcutIfNeeded(for: name) 488 | } 489 | 490 | private static let userDefaultsPrefix = "KeyboardShortcuts_" 491 | 492 | private static func userDefaultsKey(for shortcutName: Name) -> String { "\(userDefaultsPrefix)\(shortcutName.rawValue)" 493 | } 494 | 495 | static func userDefaultsDidChange(name: Name) { 496 | // TODO: Use proper UserDefaults observation instead of this. 497 | NotificationCenter.default.post(name: .shortcutByNameDidChange, object: nil, userInfo: ["name": name]) 498 | } 499 | 500 | static func userDefaultsSet(name: Name, shortcut: Shortcut) { 501 | guard let encoded = try? JSONEncoder().encode(shortcut).toString else { 502 | return 503 | } 504 | 505 | if let oldShortcut = getShortcut(for: name) { 506 | unregister(oldShortcut) 507 | } 508 | 509 | register(shortcut) 510 | UserDefaults.standard.set(encoded, forKey: userDefaultsKey(for: name)) 511 | userDefaultsDidChange(name: name) 512 | } 513 | 514 | static func userDefaultsDisable(name: Name) { 515 | guard let shortcut = getShortcut(for: name) else { 516 | return 517 | } 518 | 519 | UserDefaults.standard.set(false, forKey: userDefaultsKey(for: name)) 520 | unregister(shortcut) 521 | userDefaultsDidChange(name: name) 522 | } 523 | 524 | static func userDefaultsRemove(name: Name) { 525 | guard let shortcut = getShortcut(for: name) else { 526 | return 527 | } 528 | 529 | UserDefaults.standard.removeObject(forKey: userDefaultsKey(for: name)) 530 | unregister(shortcut) 531 | userDefaultsDidChange(name: name) 532 | } 533 | 534 | static func userDefaultsContains(name: Name) -> Bool { 535 | UserDefaults.standard.object(forKey: userDefaultsKey(for: name)) != nil 536 | } 537 | } 538 | 539 | extension KeyboardShortcuts { 540 | public enum EventType: Sendable { 541 | case keyDown 542 | case keyUp 543 | } 544 | 545 | /** 546 | Listen to the keyboard shortcut with the given name being pressed. 547 | 548 | You can register multiple listeners. 549 | 550 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do. 551 | 552 | Ending the async sequence will stop the listener. For example, in the below example, the listener will stop when the view disappears. 553 | 554 | ```swift 555 | import SwiftUI 556 | import KeyboardShortcuts 557 | 558 | struct ContentView: View { 559 | @State private var isUnicornMode = false 560 | 561 | var body: some View { 562 | Text(isUnicornMode ? "🦄" : "🐴") 563 | .task { 564 | for await event in KeyboardShortcuts.events(for: .toggleUnicornMode) where event == .keyUp { 565 | isUnicornMode.toggle() 566 | } 567 | } 568 | } 569 | } 570 | ``` 571 | 572 | - Note: This method is not affected by `.removeAllHandlers()`. 573 | */ 574 | public static func events(for name: Name) -> AsyncStream { 575 | AsyncStream { continuation in 576 | let id = UUID() 577 | 578 | DispatchQueue.main.async { 579 | streamKeyDownHandlers[name, default: [:]][id] = { 580 | continuation.yield(.keyDown) 581 | } 582 | 583 | streamKeyUpHandlers[name, default: [:]][id] = { 584 | continuation.yield(.keyUp) 585 | } 586 | 587 | registerShortcutIfNeeded(for: name) 588 | } 589 | 590 | continuation.onTermination = { _ in 591 | DispatchQueue.main.async { 592 | streamKeyDownHandlers[name]?[id] = nil 593 | streamKeyUpHandlers[name]?[id] = nil 594 | 595 | unregisterShortcutIfNeeded(for: name) 596 | } 597 | } 598 | } 599 | } 600 | 601 | /** 602 | Listen to keyboard shortcut events with the given name and type. 603 | 604 | You can register multiple listeners. 605 | 606 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do. 607 | 608 | Ending the async sequence will stop the listener. For example, in the below example, the listener will stop when the view disappears. 609 | 610 | ```swift 611 | import SwiftUI 612 | import KeyboardShortcuts 613 | 614 | struct ContentView: View { 615 | @State private var isUnicornMode = false 616 | 617 | var body: some View { 618 | Text(isUnicornMode ? "🦄" : "🐴") 619 | .task { 620 | for await event in KeyboardShortcuts.events(for: .toggleUnicornMode) where event == .keyUp { 621 | isUnicornMode.toggle() 622 | } 623 | } 624 | } 625 | } 626 | ``` 627 | 628 | - Note: This method is not affected by `.removeAllHandlers()`. 629 | */ 630 | public static func events(_ type: EventType, for name: Name) -> AsyncFilterSequence> { 631 | events(for: name).filter { $0 == type } 632 | } 633 | } 634 | 635 | extension Notification.Name { 636 | static let shortcutByNameDidChange = Self("KeyboardShortcuts_shortcutByNameDidChange") 637 | } 638 | #endif 639 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/ar.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "سجل اختصاراً"; 2 | "press_shortcut" = "اضغط على الاختصار"; 3 | "keyboard_shortcut_used_by_menu_item" = "لا يمكن استخدام اختصار لوحة المفاتيح هذا لأنه مستخدم بواسطة عنصر القائمة “%@”."; 4 | "keyboard_shortcut_used_by_system" = "لا يمكن استخدام اختصار لوحة المفاتيح هذا لأنه مستخدم مسبقاً على مستوى النظام."; 5 | "keyboard_shortcuts_can_be_changed" = "يمكن تغيير معظم اختصارات لوحة المفاتيح على مستوى النظام في “تفضيلات النظام > لوحة المفاتيح > الاختصارات ”."; 6 | "keyboard_shortcut_disallowed" = "يجب دمج مفتاح Option مع Command أو Control."; 7 | "ok" = "موافق"; 8 | "space_key" = "مسافة"; 9 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/cs.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "Přidat zkratku"; 2 | "press_shortcut" = "Zadejte klávesy"; 3 | "keyboard_shortcut_used_by_menu_item" = "Tuto zkratku nelze použít, protože je již využívána položkou „%@“"; 4 | "keyboard_shortcut_used_by_system" = "Tuto zkratku nelze použít, protože už ji používá systém."; 5 | "keyboard_shortcuts_can_be_changed" = "Většinu systémových zkratek můžete změnit v „Nastavení systému › Klávesnice › Klávesové zkratky“."; 6 | "keyboard_shortcut_disallowed" = "Modifikátor Option musí být kombinován s klávesou Command nebo Control."; 7 | "ok" = "OK"; 8 | "space_key" = "Mezera"; 9 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/de.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "Kurzbefehl aufnehmen"; 2 | "press_shortcut" = "Kurzbefehl wählen…"; 3 | "keyboard_shortcut_used_by_menu_item" = "Dieses Tastaturkürzel kann nicht verwendet werden, da es bereits durch den Menüpunkt „%@” belegt ist."; 4 | "keyboard_shortcut_used_by_system" = "Dieses Tastaturkürzel kann nicht verwendet werden, da es bereits systemweit verwendet wird."; 5 | "keyboard_shortcuts_can_be_changed" = "Die meisten systemweiten Tastaturkürzel können unter „Systemeinstellungen › Tastatur › Tastaturkurzbefehle“ geändert werden."; 6 | "keyboard_shortcut_disallowed" = "Die Option-Taste muss mit der Befehlstaste oder der Steuerungstaste kombiniert werden."; 7 | "ok" = "OK"; 8 | "space_key" = "Leer"; 9 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "Record Shortcut"; 2 | "press_shortcut" = "Press Shortcut"; 3 | "keyboard_shortcut_used_by_menu_item" = "This keyboard shortcut cannot be used as it’s already used by the “%@” menu item."; 4 | "keyboard_shortcut_used_by_system" = "This keyboard shortcut cannot be used as it’s already a system-wide keyboard shortcut."; 5 | "keyboard_shortcuts_can_be_changed" = "Most system-wide keyboard shortcuts can be changed in “System Settings › Keyboard › Keyboard Shortcuts”."; 6 | "keyboard_shortcut_disallowed" = "Option modifier must be combined with Command or Control."; 7 | "force_use_shortcut" = "Use Anyway"; 8 | "ok" = "OK"; 9 | "space_key" = "Space"; 10 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/es.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "Grabar atajo"; 2 | "press_shortcut" = "Pulsar atajo"; 3 | "keyboard_shortcut_used_by_menu_item" = "Este atajo de teclado no se puede utilizar ya que está siendo utilizado por el elemento de menú “%@”."; 4 | "keyboard_shortcut_used_by_system" = "Este atajo de teclado no se puede utilizar ya que está siendo utilizado por un atajo del sistema operativo."; 5 | "keyboard_shortcuts_can_be_changed" = "La mayoría de los atajos de teclado del sistema operativo pueden ser modificados en “Configuración del sistema › Teclado › Atajos de teclado“."; 6 | "keyboard_shortcut_disallowed" = "El modificador Option debe combinarse con Command o Control."; 7 | "ok" = "Aceptar"; 8 | "space_key" = "Espacio"; 9 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/fr.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "Enregistrer le raccourci"; 2 | "press_shortcut" = "Saisir un raccourci"; 3 | "keyboard_shortcut_used_by_menu_item" = "Ce raccourci ne peut pas être utilisé, car il est déjà utilisé par le menu “%@”."; 4 | "keyboard_shortcut_used_by_system" = "Ce raccourci ne peut pas être utilisé car il s'agit d'un raccourci déjà présent dans le système."; 5 | "keyboard_shortcuts_can_be_changed" = "La plupart des raccourcis clavier de l'ensemble du système peuvent être modifiés en “Réglages du système… › Clavier › Raccourcis clavier…”."; 6 | "keyboard_shortcut_disallowed" = "Le modificateur Option doit être combiné avec Command ou Control."; 7 | "ok" = "OK"; 8 | "space_key" = "Espace"; 9 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/hu.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "Billentyűparancs rögzítése"; 2 | "press_shortcut" = "Nyomja meg a billentyűparancsot"; 3 | "keyboard_shortcut_used_by_menu_item" = "Ez a billentyűparancs nem használható mert már a “%@” menü elem használja."; 4 | "keyboard_shortcut_used_by_system" = "Ez a billentyűparancs nem használható mert már egy rendszerszintü billentyűparancs."; 5 | "keyboard_shortcuts_can_be_changed" = "A legtöbb rendszerszintü billentyűparancsot a “Rendszerbeállítások › Billentyűzet › Billentyűparancsok“ menüben meg lehet változtatni"; 6 | "keyboard_shortcut_disallowed" = "Az Option módosítót a Command vagy Control billentyűvel együtt kell használni."; 7 | "ok" = "OK"; 8 | "space_key" = "Szóköz"; 9 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/ja.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "キーを記録"; 2 | "press_shortcut" = "キーを押してください"; 3 | "keyboard_shortcut_used_by_menu_item" = "このショートカットは既に“%@”で使われており使えません。"; 4 | "keyboard_shortcut_used_by_system" = "このショートカットキーは既にシステムで使われており使えません。"; 5 | "keyboard_shortcuts_can_be_changed" = "システムで設定されているショートカットキーは“システム設定 › キーボード › キーボードショートカット”で変更できます。"; 6 | "keyboard_shortcut_disallowed" = "Optionキーは、CommandキーまたはControlキーと組み合わせる必要があります。"; 7 | "force_use_shortcut" = "強制使用"; 8 | "ok" = "OK"; 9 | "space_key" = "スペース"; 10 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/ko.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "단축키 등록"; 2 | "press_shortcut" = "단축키 입력"; 3 | "keyboard_shortcut_used_by_menu_item" = "이 키보드 단축키는 이미 “%@” 메뉴 항목에 사용되고 있으므로 등록할 수 없습니다."; 4 | "keyboard_shortcut_used_by_system" = "이 키보드 단축키는 이미 시스템상에서 사용되고 있으므로 등록할 수 없습니다."; 5 | "keyboard_shortcuts_can_be_changed" = "대부분의 시스템 키보드 단축키는 “시스템 설정 › 키보드 › 키보드 단축키”에서 변경 가능합니다."; 6 | "keyboard_shortcut_disallowed" = "Option 수정자는 Command 또는 Control과 함께 사용해야 합니다."; 7 | "ok" = "확인"; 8 | "space_key" = "빈칸"; 9 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/nl.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "Toetscombinatie opnemen"; 2 | "press_shortcut" = "Voer toetscombinatie in"; 3 | "keyboard_shortcut_used_by_menu_item" = "Deze toetscombinatie kan niet worden gebruikt omdat hij al wordt gebruikt door het menu item “%@”."; 4 | "keyboard_shortcut_used_by_system" = "Deze toetscombinatie kan niet worden gebruikt omdat hij al door het systeem gebruikt wordt."; 5 | "keyboard_shortcuts_can_be_changed" = "De meeste systeem toetscombinaties kunnen onder “Systeeminstellingen… > Toetsenbord > Toetscombinaties…” veranderd worden."; 6 | "keyboard_shortcut_disallowed" = "De Option-toets moet worden gecombineerd met Command of Control."; 7 | "ok" = "OK"; 8 | "space_key" = "spatie"; 9 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/pt-BR.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "Gravar Atalho"; 2 | "press_shortcut" = "Digite o Atalho"; 3 | "keyboard_shortcut_used_by_menu_item" = "Este atalho não pode ser usado porque ele já é usado pelo item de menu “%@”."; 4 | "keyboard_shortcut_used_by_system" = "Este atalho não pode ser usado porque ele já é usado por um atalho do sistema."; 5 | "keyboard_shortcuts_can_be_changed" = "A maioria dos atalhos do sistema podem ser alterados em “Ajustes do Sistema › Teclado › Atalhos de Teclado”."; 6 | "keyboard_shortcut_disallowed" = "O modificador Option deve ser combinado com Command ou Control."; 7 | "ok" = "OK"; 8 | "space_key" = "Espaço"; 9 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/ru.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "Добавить"; 2 | "press_shortcut" = "Запись…"; 3 | "keyboard_shortcut_used_by_menu_item" = "Это сочетание клавиш нельзя использовать, так как оно уже используется в пункте меню «%@»."; 4 | "keyboard_shortcut_used_by_system" = "Это сочетание клавиш нельзя использовать, поскольку оно является системным."; 5 | "keyboard_shortcuts_can_be_changed" = "Большинство системных сочетаний клавиш можно изменить в «Системные настройки › Клавиатура › Сочетания клавиш»."; 6 | "keyboard_shortcut_disallowed" = "Клавиша Option должна использоваться в сочетании с Command или Control."; 7 | "ok" = "OK"; 8 | "space_key" = "Пробел"; 9 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/sk.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "Nahraj skratku"; 2 | "press_shortcut" = "Stlač skratku"; 3 | "keyboard_shortcut_used_by_menu_item" = "Túto klávesovú skratku nemožno použiť, pretože ju už používa položka menu “%@”."; 4 | "keyboard_shortcut_used_by_system" = "Túto klávesovú skratku nemožno použiť, pretože je už použivaná pre celý systém."; 5 | "keyboard_shortcuts_can_be_changed" = "Väčšinu systémových klávesových skratiek môžeš zmeniť v “Systémové nastavenia › Klávesnica › Klávesové skratky”."; 6 | "keyboard_shortcut_disallowed" = "Modifikátor Option musí byť kombinovaný s klávesmi Command alebo Control."; 7 | "force_use_shortcut" = "Použiť napriek tomu"; 8 | "ok" = "OK"; 9 | "space_key" = "Medzerník"; 10 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/zh-Hans.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "设置快捷键"; 2 | "press_shortcut" = "按下快捷键"; 3 | "keyboard_shortcut_used_by_menu_item" = "当前快捷键无法使用,因为它已被用作菜单项“%@”的快捷键。"; 4 | "keyboard_shortcut_used_by_system" = "当前快捷键无法使用,因为它已被用作系统快捷键。"; 5 | "keyboard_shortcuts_can_be_changed" = "可以在“系统设置 › 键盘 › 键盘快捷键”中更改大多数系统快捷键。"; 6 | "keyboard_shortcut_disallowed" = "Option键必须与Command键或Control键组合使用。"; 7 | "force_use_shortcut" = "强制使用"; 8 | "ok" = "好"; 9 | "space_key" = "空格"; 10 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Localization/zh-TW.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "record_shortcut" = "設定快速鍵"; 2 | "press_shortcut" = "按下快速鍵"; 3 | "keyboard_shortcut_used_by_menu_item" = "此快速鍵無法使用,因為它已被選單項目「%@」使用。"; 4 | "keyboard_shortcut_used_by_system" = "此快速鍵無法使用,因為它已被系統使用。"; 5 | "keyboard_shortcuts_can_be_changed" = "可以在「系統設定 › 鍵盤 › 鍵盤快速鍵」中更改大多數的系統快速鍵。"; 6 | "keyboard_shortcut_disallowed" = "Option鍵必須與Command鍵或Control鍵組合使用。"; 7 | "force_use_shortcut" = "強制使用"; 8 | "ok" = "好"; 9 | "space_key" = "空格"; 10 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/NSMenuItem++.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import AppKit 3 | 4 | extension NSMenuItem { 5 | private enum AssociatedKeys { 6 | @MainActor 7 | static let observer = ObjectAssociation() 8 | } 9 | 10 | @MainActor 11 | private func clearShortcut() { 12 | keyEquivalent = "" 13 | keyEquivalentModifierMask = [] 14 | 15 | if #available(macOS 12, *) { 16 | allowsAutomaticKeyEquivalentLocalization = true 17 | } 18 | } 19 | 20 | // TODO: Make this a getter/setter. We must first add the ability to create a `Shortcut` from a `keyEquivalent`. 21 | /** 22 | Show a recorded keyboard shortcut in a `NSMenuItem`. 23 | 24 | The menu item will automatically be kept up to date with changes to the keyboard shortcut. 25 | 26 | Pass in `nil` to clear the keyboard shortcut. 27 | 28 | This method overrides `.keyEquivalent` and `.keyEquivalentModifierMask`. 29 | 30 | ```swift 31 | import AppKit 32 | import KeyboardShortcuts 33 | 34 | extension KeyboardShortcuts.Name { 35 | static let toggleUnicornMode = Self("toggleUnicornMode") 36 | } 37 | 38 | // … `Recorder` logic for recording the keyboard shortcut … 39 | 40 | let menuItem = NSMenuItem() 41 | menuItem.title = "Toggle Unicorn Mode" 42 | menuItem.setShortcut(for: .toggleUnicornMode) 43 | ``` 44 | 45 | You can test this method in the example project. Run it, record a shortcut and then look at the “Test” menu in the app's main menu. 46 | 47 | - Important: You will have to disable the global keyboard shortcut while the menu is open, as otherwise, the keyboard events will be buffered up and triggered when the menu closes. This is because `NSMenu` puts the thread in tracking-mode, which prevents the keyboard events from being received. You can listen to whether a menu is open by implementing `NSMenuDelegate#menuWillOpen` and `NSMenuDelegate#menuDidClose`. You then use `KeyboardShortcuts.disable` and `KeyboardShortcuts.enable`. 48 | */ 49 | @MainActor 50 | public func setShortcut(for name: KeyboardShortcuts.Name?) { 51 | guard let name else { 52 | clearShortcut() 53 | NotificationCenter.default.removeObserver(AssociatedKeys.observer[self] as Any) 54 | AssociatedKeys.observer[self] = nil 55 | return 56 | } 57 | 58 | func set() { 59 | let shortcut = KeyboardShortcuts.Shortcut(name: name) 60 | setShortcut(shortcut) 61 | } 62 | 63 | set() 64 | 65 | // TODO: Use AsyncStream when targeting macOS 15. 66 | AssociatedKeys.observer[self] = NotificationCenter.default.addObserver(forName: .shortcutByNameDidChange, object: nil, queue: nil) { notification in 67 | guard 68 | let nameInNotification = notification.userInfo?["name"] as? KeyboardShortcuts.Name, 69 | nameInNotification == name 70 | else { 71 | return 72 | } 73 | 74 | DispatchQueue.main.async { // TODO: Use `Task { @MainActor` 75 | set() 76 | } 77 | } 78 | } 79 | 80 | /** 81 | Add a keyboard shortcut to a `NSMenuItem`. 82 | 83 | This method is only recommended for dynamic shortcuts. In general, it's preferred to create a static shortcut name and use `NSMenuItem.setShortcut(for:)` instead. 84 | 85 | Pass in `nil` to clear the keyboard shortcut. 86 | 87 | This method overrides `.keyEquivalent` and `.keyEquivalentModifierMask`. 88 | 89 | - Important: You will have to disable the global keyboard shortcut while the menu is open, as otherwise, the keyboard events will be buffered up and triggered when the menu closes. This is because `NSMenu` puts the thread in tracking-mode, which prevents the keyboard events from being received. You can listen to whether a menu is open by implementing `NSMenuDelegate#menuWillOpen` and `NSMenuDelegate#menuDidClose`. You then use `KeyboardShortcuts.disable` and `KeyboardShortcuts.enable`. 90 | */ 91 | @_disfavoredOverload 92 | @MainActor 93 | public func setShortcut(_ shortcut: KeyboardShortcuts.Shortcut?) { 94 | guard let shortcut else { 95 | clearShortcut() 96 | return 97 | } 98 | 99 | keyEquivalent = shortcut.nsMenuItemKeyEquivalent ?? "" 100 | keyEquivalentModifierMask = shortcut.modifiers 101 | 102 | if #available(macOS 12, *) { 103 | allowsAutomaticKeyEquivalentLocalization = false 104 | } 105 | } 106 | } 107 | #endif 108 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Name.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | extension KeyboardShortcuts { 3 | /** 4 | The strongly-typed name of the keyboard shortcut. 5 | 6 | After registering it, you can use it in, for example, `KeyboardShortcut.Recorder` and `KeyboardShortcut.onKeyUp()`. 7 | 8 | ```swift 9 | import KeyboardShortcuts 10 | 11 | extension KeyboardShortcuts.Name { 12 | static let toggleUnicornMode = Self("toggleUnicornMode") 13 | } 14 | ``` 15 | */ 16 | public struct Name: Hashable, Sendable { 17 | // This makes it possible to use `Shortcut` without the namespace. 18 | /// :nodoc: 19 | public typealias Shortcut = KeyboardShortcuts.Shortcut 20 | 21 | public let rawValue: String 22 | public let defaultShortcut: Shortcut? 23 | 24 | /** 25 | The keyboard shortcut assigned to the name. 26 | */ 27 | public var shortcut: Shortcut? { 28 | get { KeyboardShortcuts.getShortcut(for: self) } 29 | nonmutating set { 30 | KeyboardShortcuts.setShortcut(newValue, for: self) 31 | } 32 | } 33 | 34 | /** 35 | - Parameter name: Name of the shortcut. 36 | - Parameter initialShortcut: Optional default key combination. Do not set this unless it's essential. Users find it annoying when random apps steal their existing keyboard shortcuts. It's generally better to show a welcome screen on the first app launch that lets the user set the shortcut. 37 | */ 38 | public init(_ name: String, default initialShortcut: Shortcut? = nil) { 39 | self.rawValue = name 40 | self.defaultShortcut = initialShortcut 41 | 42 | if 43 | let initialShortcut, 44 | !userDefaultsContains(name: self) 45 | { 46 | setShortcut(initialShortcut, for: self) 47 | } 48 | 49 | KeyboardShortcuts.initialize() 50 | } 51 | } 52 | } 53 | 54 | extension KeyboardShortcuts.Name: RawRepresentable { 55 | /// :nodoc: 56 | public init?(rawValue: String) { 57 | self.init(rawValue) 58 | } 59 | } 60 | #endif 61 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Recorder.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import SwiftUI 3 | 4 | extension KeyboardShortcuts { 5 | private struct _Recorder: NSViewRepresentable { // swiftlint:disable:this type_name 6 | typealias NSViewType = RecorderCocoa 7 | 8 | let name: Name 9 | let onChange: ((_ shortcut: Shortcut?) -> Void)? 10 | 11 | func makeNSView(context: Context) -> NSViewType { 12 | .init(for: name, onChange: onChange) 13 | } 14 | 15 | func updateNSView(_ nsView: NSViewType, context: Context) { 16 | nsView.shortcutName = name 17 | } 18 | } 19 | 20 | /** 21 | A SwiftUI `View` that lets the user record a keyboard shortcut. 22 | 23 | You would usually put this in your settings window. 24 | 25 | It automatically prevents choosing a keyboard shortcut that is already taken by the system or by the app's main menu by showing a user-friendly alert to the user. 26 | 27 | It takes care of storing the keyboard shortcut in `UserDefaults` for you. 28 | 29 | ```swift 30 | import SwiftUI 31 | import KeyboardShortcuts 32 | 33 | struct SettingsScreen: View { 34 | var body: some View { 35 | Form { 36 | KeyboardShortcuts.Recorder("Toggle Unicorn Mode:", name: .toggleUnicornMode) 37 | } 38 | } 39 | } 40 | ``` 41 | 42 | - Note: Since macOS 15, for sandboxed apps, it's [no longer possible](https://developer.apple.com/forums/thread/763878?answerId=804374022#804374022) to specify the `Option` key without also using `Command` or `Control`. 43 | */ 44 | public struct Recorder: View { // swiftlint:disable:this type_name 45 | private let name: Name 46 | private let onChange: ((Shortcut?) -> Void)? 47 | private let hasLabel: Bool 48 | private let label: Label 49 | 50 | init( 51 | for name: Name, 52 | onChange: ((Shortcut?) -> Void)? = nil, 53 | hasLabel: Bool, 54 | @ViewBuilder label: () -> Label 55 | ) { 56 | self.name = name 57 | self.onChange = onChange 58 | self.hasLabel = hasLabel 59 | self.label = label() 60 | } 61 | 62 | public var body: some View { 63 | if hasLabel { 64 | if #available(macOS 13, *) { 65 | LabeledContent { 66 | _Recorder( 67 | name: name, 68 | onChange: onChange 69 | ) 70 | } label: { 71 | label 72 | } 73 | } else { 74 | _Recorder( 75 | name: name, 76 | onChange: onChange 77 | ) 78 | .formLabel { 79 | label 80 | } 81 | } 82 | } else { 83 | _Recorder( 84 | name: name, 85 | onChange: onChange 86 | ) 87 | } 88 | } 89 | } 90 | } 91 | 92 | extension KeyboardShortcuts.Recorder { 93 | /** 94 | - Parameter name: Strongly-typed keyboard shortcut name. 95 | - Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user. This can be useful when you need more control. For example, when migrating from a different keyboard shortcut solution and you need to store the keyboard shortcut somewhere yourself instead of relying on the built-in storage. However, it's strongly recommended to just rely on the built-in storage when possible. 96 | */ 97 | public init( 98 | for name: KeyboardShortcuts.Name, 99 | onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil 100 | ) { 101 | self.init( 102 | for: name, 103 | onChange: onChange, 104 | hasLabel: false 105 | ) {} 106 | } 107 | } 108 | 109 | extension KeyboardShortcuts.Recorder { 110 | /** 111 | - Parameter title: The title of the keyboard shortcut recorder, describing its purpose. 112 | - Parameter name: Strongly-typed keyboard shortcut name. 113 | - Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user. This can be useful when you need more control. For example, when migrating from a different keyboard shortcut solution and you need to store the keyboard shortcut somewhere yourself instead of relying on the built-in storage. However, it's strongly recommended to just rely on the built-in storage when possible. 114 | */ 115 | public init( 116 | _ title: LocalizedStringKey, 117 | name: KeyboardShortcuts.Name, 118 | onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil 119 | ) { 120 | self.init( 121 | for: name, 122 | onChange: onChange, 123 | hasLabel: true 124 | ) { 125 | Text(title) 126 | } 127 | } 128 | } 129 | 130 | extension KeyboardShortcuts.Recorder { 131 | /** 132 | - Parameter title: The title of the keyboard shortcut recorder, describing its purpose. 133 | - Parameter name: Strongly-typed keyboard shortcut name. 134 | - Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user. This can be useful when you need more control. For example, when migrating from a different keyboard shortcut solution and you need to store the keyboard shortcut somewhere yourself instead of relying on the built-in storage. However, it's strongly recommended to just rely on the built-in storage when possible. 135 | */ 136 | @_disfavoredOverload 137 | public init( 138 | _ title: String, 139 | name: KeyboardShortcuts.Name, 140 | onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil 141 | ) { 142 | self.init( 143 | for: name, 144 | onChange: onChange, 145 | hasLabel: true 146 | ) { 147 | Text(title) 148 | } 149 | } 150 | } 151 | 152 | extension KeyboardShortcuts.Recorder { 153 | /** 154 | - Parameter name: Strongly-typed keyboard shortcut name. 155 | - Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user. This can be useful when you need more control. For example, when migrating from a different keyboard shortcut solution and you need to store the keyboard shortcut somewhere yourself instead of relying on the built-in storage. However, it's strongly recommended to just rely on the built-in storage when possible. 156 | - Parameter label: A view that describes the purpose of the keyboard shortcut recorder. 157 | */ 158 | public init( 159 | for name: KeyboardShortcuts.Name, 160 | onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil, 161 | @ViewBuilder label: () -> Label 162 | ) { 163 | self.init( 164 | for: name, 165 | onChange: onChange, 166 | hasLabel: true, 167 | label: label 168 | ) 169 | } 170 | } 171 | 172 | #Preview { 173 | KeyboardShortcuts.Recorder("record_shortcut", name: .init("xcodePreview")) 174 | .environment(\.locale, .init(identifier: "en")) 175 | } 176 | 177 | #Preview { 178 | KeyboardShortcuts.Recorder("record_shortcut", name: .init("xcodePreview")) 179 | .environment(\.locale, .init(identifier: "zh-Hans")) 180 | } 181 | 182 | #Preview { 183 | KeyboardShortcuts.Recorder("record_shortcut", name: .init("xcodePreview")) 184 | .environment(\.locale, .init(identifier: "ru")) 185 | } 186 | #endif 187 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/RecorderCocoa.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import AppKit 3 | import Carbon.HIToolbox 4 | 5 | extension KeyboardShortcuts { 6 | /** 7 | A `NSView` that lets the user record a keyboard shortcut. 8 | 9 | You would usually put this in your settings window. 10 | 11 | It automatically prevents choosing a keyboard shortcut that is already taken by the system or by the app's main menu by showing a user-friendly alert to the user. 12 | 13 | It takes care of storing the keyboard shortcut in `UserDefaults` for you. 14 | 15 | ```swift 16 | import AppKit 17 | import KeyboardShortcuts 18 | 19 | final class SettingsViewController: NSViewController { 20 | override func loadView() { 21 | view = NSView() 22 | 23 | let recorder = KeyboardShortcuts.RecorderCocoa(for: .toggleUnicornMode) 24 | view.addSubview(recorder) 25 | } 26 | } 27 | ``` 28 | */ 29 | public final class RecorderCocoa: NSSearchField, NSSearchFieldDelegate { 30 | private let minimumWidth = 130.0 31 | private let onChange: ((_ shortcut: Shortcut?) -> Void)? 32 | private var canBecomeKey = false 33 | private var eventMonitor: LocalEventMonitor? 34 | private var shortcutsNameChangeObserver: NSObjectProtocol? 35 | private var windowDidResignKeyObserver: NSObjectProtocol? 36 | private var windowDidBecomeKeyObserver: NSObjectProtocol? 37 | 38 | /** 39 | The shortcut name for the recorder. 40 | 41 | Can be dynamically changed at any time. 42 | */ 43 | public var shortcutName: Name { 44 | didSet { 45 | guard shortcutName != oldValue else { 46 | return 47 | } 48 | 49 | setStringValue(name: shortcutName) 50 | 51 | // This doesn't seem to be needed anymore, but I cannot test on older OS versions, so keeping it just in case. 52 | if #unavailable(macOS 12) { 53 | DispatchQueue.main.async { [self] in 54 | // Prevents the placeholder from being cut off. 55 | blur() 56 | } 57 | } 58 | } 59 | } 60 | 61 | /// :nodoc: 62 | override public var canBecomeKeyView: Bool { canBecomeKey } 63 | 64 | /// :nodoc: 65 | override public var intrinsicContentSize: CGSize { 66 | var size = super.intrinsicContentSize 67 | size.width = minimumWidth 68 | return size 69 | } 70 | 71 | private var cancelButton: NSButtonCell? 72 | 73 | private var showsCancelButton: Bool { 74 | get { (cell as? NSSearchFieldCell)?.cancelButtonCell != nil } 75 | set { 76 | (cell as? NSSearchFieldCell)?.cancelButtonCell = newValue ? cancelButton : nil 77 | } 78 | } 79 | 80 | /** 81 | - Parameter name: Strongly-typed keyboard shortcut name. 82 | - Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user. This can be useful when you need more control. For example, when migrating from a different keyboard shortcut solution and you need to store the keyboard shortcut somewhere yourself instead of relying on the built-in storage. However, it's strongly recommended to just rely on the built-in storage when possible. 83 | */ 84 | public required init( 85 | for name: Name, 86 | onChange: ((_ shortcut: Shortcut?) -> Void)? = nil 87 | ) { 88 | self.shortcutName = name 89 | self.onChange = onChange 90 | 91 | super.init(frame: .zero) 92 | self.delegate = self 93 | self.placeholderString = "record_shortcut".localized 94 | self.alignment = .center 95 | (cell as? NSSearchFieldCell)?.searchButtonCell = nil 96 | 97 | self.wantsLayer = true 98 | setContentHuggingPriority(.defaultHigh, for: .vertical) 99 | setContentHuggingPriority(.defaultHigh, for: .horizontal) 100 | 101 | // Hide the cancel button when not showing the shortcut so the placeholder text is properly centered. Must be last. 102 | self.cancelButton = (cell as? NSSearchFieldCell)?.cancelButtonCell 103 | 104 | setStringValue(name: name) 105 | 106 | setUpEvents() 107 | } 108 | 109 | @available(*, unavailable) 110 | public required init?(coder: NSCoder) { 111 | fatalError("init(coder:) has not been implemented") 112 | } 113 | 114 | private func setStringValue(name: KeyboardShortcuts.Name) { 115 | stringValue = getShortcut(for: shortcutName).map { "\($0)" } ?? "" 116 | 117 | // If `stringValue` is empty, hide the cancel button to let the placeholder center. 118 | showsCancelButton = !stringValue.isEmpty 119 | } 120 | 121 | private func setUpEvents() { 122 | shortcutsNameChangeObserver = NotificationCenter.default.addObserver(forName: .shortcutByNameDidChange, object: nil, queue: nil) { [weak self] notification in 123 | guard 124 | let self, 125 | let nameInNotification = notification.userInfo?["name"] as? KeyboardShortcuts.Name, 126 | nameInNotification == shortcutName 127 | else { 128 | return 129 | } 130 | 131 | setStringValue(name: nameInNotification) 132 | } 133 | } 134 | 135 | private func endRecording() { 136 | eventMonitor = nil 137 | placeholderString = "record_shortcut".localized 138 | showsCancelButton = !stringValue.isEmpty 139 | restoreCaret() 140 | KeyboardShortcuts.isPaused = false 141 | NotificationCenter.default.post(name: .recorderActiveStatusDidChange, object: nil, userInfo: ["isActive": false]) 142 | } 143 | 144 | private func preventBecomingKey() { 145 | canBecomeKey = false 146 | 147 | // Prevent the control from receiving the initial focus. 148 | DispatchQueue.main.async { [self] in 149 | canBecomeKey = true 150 | } 151 | } 152 | 153 | /// :nodoc: 154 | public func controlTextDidChange(_ object: Notification) { 155 | if stringValue.isEmpty { 156 | saveShortcut(nil) 157 | } 158 | 159 | showsCancelButton = !stringValue.isEmpty 160 | 161 | if stringValue.isEmpty { 162 | // Hack to ensure that the placeholder centers after the above `showsCancelButton` setter. 163 | focus() 164 | } 165 | } 166 | 167 | /// :nodoc: 168 | public func controlTextDidEndEditing(_ object: Notification) { 169 | endRecording() 170 | } 171 | 172 | /// :nodoc: 173 | override public func viewDidMoveToWindow() { 174 | guard let window else { 175 | windowDidResignKeyObserver = nil 176 | windowDidBecomeKeyObserver = nil 177 | endRecording() 178 | return 179 | } 180 | 181 | // Ensures the recorder stops when the window is hidden. 182 | // This is especially important for Settings windows, which as of macOS 13.5, only hides instead of closes when you click the close button. 183 | windowDidResignKeyObserver = NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification, object: window, queue: nil) { [weak self] _ in 184 | guard 185 | let self, 186 | let window = self.window 187 | else { 188 | return 189 | } 190 | 191 | endRecording() 192 | window.makeFirstResponder(nil) 193 | } 194 | 195 | // Ensures the recorder does not receive initial focus when a hidden window becomes unhidden. 196 | windowDidBecomeKeyObserver = NotificationCenter.default.addObserver(forName: NSWindow.didBecomeKeyNotification, object: window, queue: nil) { [weak self] _ in 197 | self?.preventBecomingKey() 198 | } 199 | 200 | preventBecomingKey() 201 | } 202 | 203 | /// :nodoc: 204 | override public func becomeFirstResponder() -> Bool { 205 | let shouldBecomeFirstResponder = super.becomeFirstResponder() 206 | 207 | guard shouldBecomeFirstResponder else { 208 | return shouldBecomeFirstResponder 209 | } 210 | 211 | placeholderString = "press_shortcut".localized 212 | showsCancelButton = !stringValue.isEmpty 213 | hideCaret() 214 | KeyboardShortcuts.isPaused = true // The position here matters. 215 | NotificationCenter.default.post(name: .recorderActiveStatusDidChange, object: nil, userInfo: ["isActive": true]) 216 | 217 | eventMonitor = LocalEventMonitor(events: [.keyDown, .leftMouseUp, .rightMouseUp]) { [weak self] event in 218 | guard let self else { 219 | return nil 220 | } 221 | 222 | let clickPoint = convert(event.locationInWindow, from: nil) 223 | let clickMargin = 3.0 224 | 225 | if 226 | event.type == .leftMouseUp || event.type == .rightMouseUp, 227 | !bounds.insetBy(dx: -clickMargin, dy: -clickMargin).contains(clickPoint) 228 | { 229 | blur() 230 | return event 231 | } 232 | 233 | guard event.isKeyEvent else { 234 | return nil 235 | } 236 | 237 | if 238 | event.modifiers.isEmpty, 239 | event.specialKey == .tab 240 | { 241 | blur() 242 | 243 | // We intentionally bubble up the event so it can focus the next responder. 244 | return event 245 | } 246 | 247 | if 248 | event.modifiers.isEmpty, 249 | event.keyCode == kVK_Escape // TODO: Make this strongly typed. 250 | { 251 | blur() 252 | return nil 253 | } 254 | 255 | if 256 | event.modifiers.isEmpty, 257 | event.specialKey == .delete 258 | || event.specialKey == .deleteForward 259 | || event.specialKey == .backspace 260 | { 261 | clear() 262 | return nil 263 | } 264 | 265 | // The “shift” key is not allowed without other modifiers or a function key, since it doesn't actually work. 266 | guard 267 | !event.modifiers.subtracting([.shift, .function]).isEmpty 268 | || event.specialKey?.isFunctionKey == true, 269 | let shortcut = Shortcut(event: event) 270 | else { 271 | NSSound.beep() 272 | return nil 273 | } 274 | 275 | if let menuItem = shortcut.takenByMainMenu { 276 | // TODO: Find a better way to make it possible to dismiss the alert by pressing "Enter". How can we make the input automatically temporarily lose focus while the alert is open? 277 | blur() 278 | 279 | NSAlert.showModal( 280 | for: window, 281 | title: String.localizedStringWithFormat("keyboard_shortcut_used_by_menu_item".localized, menuItem.title) 282 | ) 283 | 284 | focus() 285 | 286 | return nil 287 | } 288 | 289 | // See: https://developer.apple.com/forums/thread/763878?answerId=804374022#804374022 290 | if shortcut.isDisallowed { 291 | blur() 292 | 293 | NSAlert.showModal( 294 | for: window, 295 | title: "keyboard_shortcut_disallowed".localized 296 | ) 297 | 298 | focus() 299 | return nil 300 | } 301 | 302 | if shortcut.isTakenBySystem { 303 | blur() 304 | 305 | let modalResponse = NSAlert.showModal( 306 | for: window, 307 | title: "keyboard_shortcut_used_by_system".localized, 308 | // TODO: Add button to offer to open the relevant system settings pane for the user. 309 | message: "keyboard_shortcuts_can_be_changed".localized, 310 | buttonTitles: [ 311 | "ok".localized, 312 | "force_use_shortcut".localized 313 | ] 314 | ) 315 | 316 | focus() 317 | 318 | // If the user has selected "Use Anyway" in the dialog (the second option), we'll continue setting the keyboard shorcut even though it's reserved by the system. 319 | guard modalResponse == .alertSecondButtonReturn else { 320 | return nil 321 | } 322 | } 323 | 324 | stringValue = "\(shortcut)" 325 | showsCancelButton = true 326 | 327 | saveShortcut(shortcut) 328 | blur() 329 | 330 | return nil 331 | }.start() 332 | 333 | return shouldBecomeFirstResponder 334 | } 335 | 336 | private func saveShortcut(_ shortcut: Shortcut?) { 337 | setShortcut(shortcut, for: shortcutName) 338 | onChange?(shortcut) 339 | } 340 | } 341 | } 342 | 343 | extension Notification.Name { 344 | static let recorderActiveStatusDidChange = Self("KeyboardShortcuts_recorderActiveStatusDidChange") 345 | } 346 | #endif 347 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Shortcut.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import AppKit 3 | import Carbon.HIToolbox 4 | import SwiftUI 5 | 6 | extension KeyboardShortcuts { 7 | /** 8 | A keyboard shortcut. 9 | */ 10 | public struct Shortcut: Hashable, Codable, Sendable { 11 | /** 12 | Carbon modifiers are not always stored as the same number. 13 | 14 | For example, the system has `⌃F2` stored with the modifiers number `135168`, but if you press the keyboard shortcut, you get `4096`. 15 | */ 16 | private static func normalizeModifiers(_ carbonModifiers: Int) -> Int { 17 | NSEvent.ModifierFlags(carbon: carbonModifiers).carbon 18 | } 19 | 20 | /** 21 | The keyboard key of the shortcut. 22 | */ 23 | public var key: Key? { Key(rawValue: carbonKeyCode) } 24 | 25 | /** 26 | The modifier keys of the shortcut. 27 | */ 28 | public var modifiers: NSEvent.ModifierFlags { NSEvent.ModifierFlags(carbon: carbonModifiers) } 29 | 30 | /** 31 | Low-level represetation of the key. 32 | 33 | You most likely don't need this. 34 | */ 35 | public let carbonKeyCode: Int 36 | 37 | /** 38 | Low-level representation of the modifier keys. 39 | 40 | You most likely don't need this. 41 | */ 42 | public let carbonModifiers: Int 43 | 44 | /** 45 | Initialize from a strongly-typed key and modifiers. 46 | */ 47 | public init(_ key: Key, modifiers: NSEvent.ModifierFlags = []) { 48 | self.init( 49 | carbonKeyCode: key.rawValue, 50 | carbonModifiers: modifiers.carbon 51 | ) 52 | } 53 | 54 | /** 55 | Initialize from a key event. 56 | */ 57 | public init?(event: NSEvent) { 58 | guard event.isKeyEvent else { 59 | return nil 60 | } 61 | 62 | self.init( 63 | carbonKeyCode: Int(event.keyCode), 64 | // Note: We could potentially support users specifying shortcuts with the Fn key, but I haven't found a reliable way to differentate when to display the Fn key and not. For example, with Fn+F1 we only want to display F1, but with Fn+V, we want to display both. I cannot just specialize it for F keys as it applies to other keys too, like Fn+arrowup. 65 | carbonModifiers: event.modifierFlags.subtracting(.function).carbon 66 | ) 67 | } 68 | 69 | /** 70 | Initialize from a keyboard shortcut stored by `Recorder` or `RecorderCocoa`. 71 | */ 72 | public init?(name: Name) { 73 | guard let shortcut = getShortcut(for: name) else { 74 | return nil 75 | } 76 | 77 | self = shortcut 78 | } 79 | 80 | /** 81 | Initialize from a key code number and modifier code. 82 | 83 | You most likely don't need this. 84 | */ 85 | public init(carbonKeyCode: Int, carbonModifiers: Int = 0) { 86 | self.carbonKeyCode = carbonKeyCode 87 | self.carbonModifiers = Self.normalizeModifiers(carbonModifiers) 88 | } 89 | } 90 | } 91 | 92 | enum Constants { 93 | static let isSandboxed = ProcessInfo.processInfo.environment.hasKey("APP_SANDBOX_CONTAINER_ID") 94 | } 95 | 96 | extension KeyboardShortcuts.Shortcut { 97 | /** 98 | System-defined keyboard shortcuts. 99 | */ 100 | static var system: [Self] { 101 | CarbonKeyboardShortcuts.system 102 | } 103 | 104 | /** 105 | Check whether the keyboard shortcut is disallowed. 106 | */ 107 | var isDisallowed: Bool { 108 | let osVersion = ProcessInfo.processInfo.operatingSystemVersion 109 | 110 | guard 111 | osVersion.majorVersion == 15, 112 | osVersion.minorVersion == 0 || osVersion.minorVersion == 1, 113 | Constants.isSandboxed 114 | else { 115 | return false 116 | } 117 | 118 | if !modifiers.contains(.option) { 119 | return false // Allowed if Option is not involved 120 | } 121 | 122 | // If Option is present, ensure there's at least one modifier other than Option and Shift 123 | let otherModifiers: NSEvent.ModifierFlags = [.command, .control, .function, .capsLock] 124 | return modifiers.isDisjoint(with: otherModifiers) 125 | } 126 | 127 | /** 128 | Check whether the keyboard shortcut is already taken by the system. 129 | */ 130 | var isTakenBySystem: Bool { 131 | guard self != Self(.f12, modifiers: []) else { 132 | return false 133 | } 134 | 135 | return Self.system.contains(self) 136 | } 137 | } 138 | 139 | extension KeyboardShortcuts.Shortcut { 140 | /** 141 | Recursively finds a menu item in the given menu that has a matching key equivalent and modifier. 142 | */ 143 | @MainActor 144 | func menuItemWithMatchingShortcut(in menu: NSMenu) -> NSMenuItem? { 145 | for item in menu.items { 146 | var keyEquivalent = item.keyEquivalent 147 | var keyEquivalentModifierMask = item.keyEquivalentModifierMask 148 | 149 | if modifiers.contains(.shift), keyEquivalent.lowercased() != keyEquivalent { 150 | keyEquivalent = keyEquivalent.lowercased() 151 | keyEquivalentModifierMask.insert(.shift) 152 | } 153 | 154 | if 155 | self.nsMenuItemKeyEquivalent == keyEquivalent, // Note `nil != ""` 156 | self.modifiers == keyEquivalentModifierMask 157 | { 158 | return item 159 | } 160 | 161 | if 162 | let submenu = item.submenu, 163 | let menuItem = menuItemWithMatchingShortcut(in: submenu) 164 | { 165 | return menuItem 166 | } 167 | } 168 | 169 | return nil 170 | } 171 | 172 | /** 173 | Returns a menu item in the app's main menu that has a matching key equivalent and modifier. 174 | */ 175 | @MainActor 176 | var takenByMainMenu: NSMenuItem? { 177 | guard let mainMenu = NSApp.mainMenu else { 178 | return nil 179 | } 180 | 181 | return menuItemWithMatchingShortcut(in: mainMenu) 182 | } 183 | } 184 | 185 | /* 186 | An enumeration of special keys requiring specific handling when used with `RecorderCocoa`, AppKit’s `NSMenuItem`, and SwiftUI’s `.keyboardShortcut(_:modifiers:)`. 187 | 188 | Using an enumeration ensures all cases are exhaustively addressed in all three contexts, providing compile-time safety and reducing the risk of unhandled keys. 189 | */ 190 | private enum SpecialKey { 191 | case `return` 192 | case delete 193 | case deleteForward 194 | case end 195 | case escape 196 | case help 197 | case home 198 | case space 199 | case tab 200 | case pageUp 201 | case pageDown 202 | case upArrow 203 | case rightArrow 204 | case downArrow 205 | case leftArrow 206 | case f1 207 | case f2 208 | case f3 209 | case f4 210 | case f5 211 | case f6 212 | case f7 213 | case f8 214 | case f9 215 | case f10 216 | case f11 217 | case f12 218 | case f13 219 | case f14 220 | case f15 221 | case f16 222 | case f17 223 | case f18 224 | case f19 225 | case f20 226 | case keypad0 227 | case keypad1 228 | case keypad2 229 | case keypad3 230 | case keypad4 231 | case keypad5 232 | case keypad6 233 | case keypad7 234 | case keypad8 235 | case keypad9 236 | case keypadClear 237 | case keypadDecimal 238 | case keypadDivide 239 | case keypadEnter 240 | case keypadEquals 241 | case keypadMinus 242 | case keypadMultiply 243 | case keypadPlus 244 | } 245 | 246 | private let keyToSpecialKeyMapping: [KeyboardShortcuts.Key: SpecialKey] = [ 247 | .return: .return, 248 | .delete: .delete, 249 | .deleteForward: .deleteForward, 250 | .end: .end, 251 | .escape: .escape, 252 | .help: .help, 253 | .home: .home, 254 | .space: .space, 255 | .tab: .tab, 256 | .pageUp: .pageUp, 257 | .pageDown: .pageDown, 258 | .upArrow: .upArrow, 259 | .rightArrow: .rightArrow, 260 | .downArrow: .downArrow, 261 | .leftArrow: .leftArrow, 262 | .f1: .f1, 263 | .f2: .f2, 264 | .f3: .f3, 265 | .f4: .f4, 266 | .f5: .f5, 267 | .f6: .f6, 268 | .f7: .f7, 269 | .f8: .f8, 270 | .f9: .f9, 271 | .f10: .f10, 272 | .f11: .f11, 273 | .f12: .f12, 274 | .f13: .f13, 275 | .f14: .f14, 276 | .f15: .f15, 277 | .f16: .f16, 278 | .f17: .f17, 279 | .f18: .f18, 280 | .f19: .f19, 281 | .f20: .f20, 282 | .keypad0: .keypad0, 283 | .keypad1: .keypad1, 284 | .keypad2: .keypad2, 285 | .keypad3: .keypad3, 286 | .keypad4: .keypad4, 287 | .keypad5: .keypad5, 288 | .keypad6: .keypad6, 289 | .keypad7: .keypad7, 290 | .keypad8: .keypad8, 291 | .keypad9: .keypad9, 292 | .keypadClear: .keypadClear, 293 | .keypadDecimal: .keypadDecimal, 294 | .keypadDivide: .keypadDivide, 295 | .keypadEnter: .keypadEnter, 296 | .keypadEquals: .keypadEquals, 297 | .keypadMinus: .keypadMinus, 298 | .keypadMultiply: .keypadMultiply, 299 | .keypadPlus: .keypadPlus 300 | ] 301 | 302 | extension SpecialKey { 303 | fileprivate var presentableDescription: String { 304 | switch self { 305 | case .return: 306 | "↩" 307 | case .delete: 308 | "⌫" 309 | case .deleteForward: 310 | "⌦" 311 | case .end: 312 | "↘" 313 | case .escape: 314 | "⎋" 315 | case .help: 316 | "?⃝" 317 | case .home: 318 | "↖" 319 | case .space: 320 | "space_key".localized.capitalized // This matches what macOS uses. 321 | case .tab: 322 | "⇥" 323 | case .pageUp: 324 | "⇞" 325 | case .pageDown: 326 | "⇟" 327 | case .upArrow: 328 | "↑" 329 | case .rightArrow: 330 | "→" 331 | case .downArrow: 332 | "↓" 333 | case .leftArrow: 334 | "←" 335 | case .f1: 336 | "F1" 337 | case .f2: 338 | "F2" 339 | case .f3: 340 | "F3" 341 | case .f4: 342 | "F4" 343 | case .f5: 344 | "F5" 345 | case .f6: 346 | "F6" 347 | case .f7: 348 | "F7" 349 | case .f8: 350 | "F8" 351 | case .f9: 352 | "F9" 353 | case .f10: 354 | "F10" 355 | case .f11: 356 | "F11" 357 | case .f12: 358 | "F12" 359 | case .f13: 360 | "F13" 361 | case .f14: 362 | "F14" 363 | case .f15: 364 | "F15" 365 | case .f16: 366 | "F16" 367 | case .f17: 368 | "F17" 369 | case .f18: 370 | "F18" 371 | case .f19: 372 | "F19" 373 | case .f20: 374 | "F20" 375 | 376 | // Representations for numeric keypad keys with ⃣ Unicode U+20e3 'COMBINING ENCLOSING KEYCAP' 377 | case .keypad0: 378 | "0\u{20e3}" 379 | case .keypad1: 380 | "1\u{20e3}" 381 | case .keypad2: 382 | "2\u{20e3}" 383 | case .keypad3: 384 | "3\u{20e3}" 385 | case .keypad4: 386 | "4\u{20e3}" 387 | case .keypad5: 388 | "5\u{20e3}" 389 | case .keypad6: 390 | "6\u{20e3}" 391 | case .keypad7: 392 | "7\u{20e3}" 393 | case .keypad8: 394 | "8\u{20e3}" 395 | case .keypad9: 396 | "9\u{20e3}" 397 | // There's "⌧“ 'X In A Rectangle Box' (U+2327), "☒" 'Ballot Box with X' (U+2612), "×" 'Multiplication Sign' (U+00d7), "⨯" 'Vector or Cross Product' (U+2a2f), or a plain small x. All combined symbols appear bigger. 398 | case .keypadClear: 399 | "☒\u{20e3}" // The combined symbol appears bigger than the other combined 'keycaps' 400 | // TODO: Respect locale decimal separator ("." or ",") 401 | case .keypadDecimal: 402 | ".\u{20e3}" 403 | case .keypadDivide: 404 | "/\u{20e3}" 405 | // "⏎" 'Return Symbol' (U+23CE) but "↩" 'Leftwards Arrow with Hook' (U+00d7) seems to be more common on macOS. 406 | case .keypadEnter: 407 | "↩\u{20e3}" // The combined symbol appears bigger than the other combined 'keycaps' 408 | case .keypadEquals: 409 | "=\u{20e3}" 410 | case .keypadMinus: 411 | "-\u{20e3}" 412 | case .keypadMultiply: 413 | "*\u{20e3}" 414 | case .keypadPlus: 415 | "+\u{20e3}" 416 | } 417 | } 418 | 419 | @available(macOS 11.0, *) 420 | fileprivate var swiftUIKeyEquivalent: SwiftUI.KeyEquivalent? { 421 | switch self { 422 | case .return: 423 | .return 424 | case .delete: 425 | .delete 426 | case .deleteForward: 427 | .deleteForward 428 | case .end: 429 | .end 430 | case .escape: 431 | .escape 432 | case .help: 433 | KeyEquivalent(unicodeScalarValue: NSHelpFunctionKey) 434 | case .home: 435 | .home 436 | case .space: 437 | .space 438 | case .tab: 439 | .tab 440 | case .pageUp: 441 | .pageUp 442 | case .pageDown: 443 | .pageDown 444 | case .upArrow: 445 | .upArrow 446 | case .rightArrow: 447 | .rightArrow 448 | case .downArrow: 449 | .downArrow 450 | case .leftArrow: 451 | .leftArrow 452 | case .f1: 453 | KeyEquivalent(unicodeScalarValue: NSF1FunctionKey) 454 | case .f2: 455 | KeyEquivalent(unicodeScalarValue: NSF2FunctionKey) 456 | case .f3: 457 | KeyEquivalent(unicodeScalarValue: NSF3FunctionKey) 458 | case .f4: 459 | KeyEquivalent(unicodeScalarValue: NSF4FunctionKey) 460 | case .f5: 461 | KeyEquivalent(unicodeScalarValue: NSF5FunctionKey) 462 | case .f6: 463 | KeyEquivalent(unicodeScalarValue: NSF6FunctionKey) 464 | case .f7: 465 | KeyEquivalent(unicodeScalarValue: NSF7FunctionKey) 466 | case .f8: 467 | KeyEquivalent(unicodeScalarValue: NSF8FunctionKey) 468 | case .f9: 469 | KeyEquivalent(unicodeScalarValue: NSF9FunctionKey) 470 | case .f10: 471 | KeyEquivalent(unicodeScalarValue: NSF10FunctionKey) 472 | case .f11: 473 | KeyEquivalent(unicodeScalarValue: NSF11FunctionKey) 474 | case .f12: 475 | KeyEquivalent(unicodeScalarValue: NSF12FunctionKey) 476 | case .f13: 477 | KeyEquivalent(unicodeScalarValue: NSF13FunctionKey) 478 | case .f14: 479 | KeyEquivalent(unicodeScalarValue: NSF14FunctionKey) 480 | case .f15: 481 | KeyEquivalent(unicodeScalarValue: NSF15FunctionKey) 482 | case .f16: 483 | KeyEquivalent(unicodeScalarValue: NSF16FunctionKey) 484 | case .f17: 485 | KeyEquivalent(unicodeScalarValue: NSF17FunctionKey) 486 | case .f18: 487 | KeyEquivalent(unicodeScalarValue: NSF18FunctionKey) 488 | case .f19: 489 | KeyEquivalent(unicodeScalarValue: NSF19FunctionKey) 490 | case .f20: 491 | KeyEquivalent(unicodeScalarValue: NSF20FunctionKey) 492 | // Neither the " ⃣" enclosed characters (e.g. "7⃣") nor regular 493 | // characters with the `.numpad` modifier produce `SwiftUI` buttons that 494 | // will capture the only the number pad's keys (last checked: MacOS 14). 495 | // Return `nil` to prevent definition of incorrect shortcuts. 496 | case .keypad0: 497 | nil 498 | case .keypad1: 499 | nil 500 | case .keypad2: 501 | nil 502 | case .keypad3: 503 | nil 504 | case .keypad4: 505 | nil 506 | case .keypad5: 507 | nil 508 | case .keypad6: 509 | nil 510 | case .keypad7: 511 | nil 512 | case .keypad8: 513 | nil 514 | case .keypad9: 515 | nil 516 | case .keypadClear: 517 | nil 518 | case .keypadDecimal: 519 | nil 520 | case .keypadDivide: 521 | nil 522 | case .keypadEnter: 523 | nil 524 | case .keypadEquals: 525 | nil 526 | case .keypadMinus: 527 | nil 528 | case .keypadMultiply: 529 | nil 530 | case .keypadPlus: 531 | nil 532 | } 533 | } 534 | 535 | fileprivate var appKitMenuItemKeyEquivalent: Character? { 536 | switch self { 537 | case .return: 538 | "↩" 539 | case .delete: 540 | "⌫" 541 | case .deleteForward: 542 | "⌦" 543 | case .end: 544 | "↘" 545 | case .escape: 546 | "⎋" 547 | case .help: 548 | "?⃝" 549 | case .home: 550 | "↖" 551 | case .space: 552 | "\u{0020}" 553 | case .tab: 554 | "⇥" 555 | case .pageUp: 556 | "⇞" 557 | case .pageDown: 558 | "⇟" 559 | case .upArrow: 560 | "↑" 561 | case .rightArrow: 562 | "→" 563 | case .downArrow: 564 | "↓" 565 | case .leftArrow: 566 | "←" 567 | case .f1: 568 | Character(unicodeScalarValue: NSF1FunctionKey) 569 | case .f2: 570 | Character(unicodeScalarValue: NSF2FunctionKey) 571 | case .f3: 572 | Character(unicodeScalarValue: NSF3FunctionKey) 573 | case .f4: 574 | Character(unicodeScalarValue: NSF4FunctionKey) 575 | case .f5: 576 | Character(unicodeScalarValue: NSF5FunctionKey) 577 | case .f6: 578 | Character(unicodeScalarValue: NSF6FunctionKey) 579 | case .f7: 580 | Character(unicodeScalarValue: NSF7FunctionKey) 581 | case .f8: 582 | Character(unicodeScalarValue: NSF8FunctionKey) 583 | case .f9: 584 | Character(unicodeScalarValue: NSF9FunctionKey) 585 | case .f10: 586 | Character(unicodeScalarValue: NSF10FunctionKey) 587 | case .f11: 588 | Character(unicodeScalarValue: NSF11FunctionKey) 589 | case .f12: 590 | Character(unicodeScalarValue: NSF12FunctionKey) 591 | case .f13: 592 | Character(unicodeScalarValue: NSF13FunctionKey) 593 | case .f14: 594 | Character(unicodeScalarValue: NSF14FunctionKey) 595 | case .f15: 596 | Character(unicodeScalarValue: NSF15FunctionKey) 597 | case .f16: 598 | Character(unicodeScalarValue: NSF16FunctionKey) 599 | case .f17: 600 | Character(unicodeScalarValue: NSF17FunctionKey) 601 | case .f18: 602 | Character(unicodeScalarValue: NSF18FunctionKey) 603 | case .f19: 604 | Character(unicodeScalarValue: NSF19FunctionKey) 605 | case .f20: 606 | Character(unicodeScalarValue: NSF20FunctionKey) 607 | // Neither the " ⃣" enclosed characters (e.g. "7⃣") nor regular 608 | // characters with the `.numericPad` modifier produce a `MenuItem` that 609 | // will capture the only the number pad's keys (last checked: MacOS 14). 610 | // Return `nil` to prevent definition of incorrect shortcuts. 611 | case .keypad0: 612 | nil 613 | case .keypad1: 614 | nil 615 | case .keypad2: 616 | nil 617 | case .keypad3: 618 | nil 619 | case .keypad4: 620 | nil 621 | case .keypad5: 622 | nil 623 | case .keypad6: 624 | nil 625 | case .keypad7: 626 | nil 627 | case .keypad8: 628 | nil 629 | case .keypad9: 630 | nil 631 | case .keypadClear: 632 | nil 633 | case .keypadDecimal: 634 | nil 635 | case .keypadDivide: 636 | nil 637 | case .keypadEnter: 638 | nil 639 | case .keypadEquals: 640 | nil 641 | case .keypadMinus: 642 | nil 643 | case .keypadMultiply: 644 | nil 645 | case .keypadPlus: 646 | nil 647 | } 648 | } 649 | } 650 | 651 | extension KeyboardShortcuts.Shortcut { 652 | @MainActor // `TISGetInputSourceProperty` crashes if called on a non-main thread. 653 | fileprivate func keyToCharacter() -> Character? { 654 | guard 655 | let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue(), 656 | let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) 657 | else { 658 | return nil 659 | } 660 | 661 | guard key.flatMap({ keyToSpecialKeyMapping[$0] }) == nil else { 662 | assertionFailure("Special keys should get special treatment and should not be translated using keyToCharacter()") 663 | return nil 664 | } 665 | 666 | let layoutData = unsafeBitCast(layoutDataPointer, to: CFData.self) 667 | let keyLayout = unsafeBitCast(CFDataGetBytePtr(layoutData), to: UnsafePointer.self) 668 | var deadKeyState: UInt32 = 0 669 | let maxLength = 4 670 | var length = 0 671 | var characters = [UniChar](repeating: 0, count: maxLength) 672 | 673 | let error = CoreServices.UCKeyTranslate( 674 | keyLayout, 675 | UInt16(carbonKeyCode), 676 | UInt16(CoreServices.kUCKeyActionDisplay), 677 | 0, // No modifiers 678 | UInt32(LMGetKbdType()), 679 | OptionBits(CoreServices.kUCKeyTranslateNoDeadKeysBit), 680 | &deadKeyState, 681 | maxLength, 682 | &length, 683 | &characters 684 | ) 685 | 686 | guard error == noErr else { 687 | return nil 688 | } 689 | 690 | let string = String(utf16CodeUnits: characters, count: length) 691 | if string.count == 1 { 692 | return string.first 693 | } 694 | 695 | return nil 696 | } 697 | 698 | /** 699 | Key equivalent string in `NSMenuItem` format. 700 | 701 | This can be used to show the keyboard shortcut in a `NSMenuItem` by assigning it to `NSMenuItem#keyEquivalent`. 702 | 703 | - Note: Don't forget to also pass ``Shortcut/modifiers`` to `NSMenuItem#keyEquivalentModifierMask`. 704 | */ 705 | @MainActor 706 | public var nsMenuItemKeyEquivalent: String? { 707 | if 708 | let key, 709 | let specialKey = keyToSpecialKeyMapping[key] 710 | { 711 | if let keyEquivalent = specialKey.appKitMenuItemKeyEquivalent { 712 | return String(keyEquivalent) 713 | } 714 | } else if let character = keyToCharacter() { 715 | return String(character) 716 | } 717 | 718 | return nil 719 | } 720 | } 721 | 722 | extension KeyboardShortcuts.Shortcut: CustomStringConvertible { 723 | /** 724 | The string representation of the keyboard shortcut. 725 | 726 | ```swift 727 | print(KeyboardShortcuts.Shortcut(.a, modifiers: [.command])) 728 | //=> "⌘A" 729 | ``` 730 | */ 731 | 732 | @MainActor 733 | var presentableDescription: String { 734 | if 735 | let key, 736 | let specialKey = keyToSpecialKeyMapping[key] 737 | { 738 | return modifiers.presentableDescription + specialKey.presentableDescription 739 | } 740 | 741 | return modifiers.presentableDescription + String(keyToCharacter() ?? "�").capitalized 742 | } 743 | 744 | @MainActor 745 | public var description: String { 746 | // TODO: `description` needs to be `nonisolated` 747 | presentableDescription 748 | } 749 | } 750 | 751 | extension KeyboardShortcuts.Shortcut { 752 | @available(macOS 11, *) 753 | @MainActor 754 | var toSwiftUI: KeyboardShortcut? { 755 | if 756 | let key, 757 | let specialKey = keyToSpecialKeyMapping[key] 758 | { 759 | if let keyEquivalent = specialKey.swiftUIKeyEquivalent { 760 | if #available(macOS 12.0, *) { 761 | return KeyboardShortcut(keyEquivalent, modifiers: modifiers.toEventModifiers, localization: .custom) 762 | } else { 763 | return KeyboardShortcut(keyEquivalent, modifiers: modifiers.toEventModifiers) 764 | } 765 | } 766 | } else if let character = keyToCharacter() { 767 | if #available(macOS 12.0, *) { 768 | return KeyboardShortcut(KeyEquivalent(character), modifiers: modifiers.toEventModifiers, localization: .custom) 769 | } else { 770 | return KeyboardShortcut(KeyEquivalent(character), modifiers: modifiers.toEventModifiers) 771 | } 772 | } 773 | 774 | return nil 775 | } 776 | } 777 | #endif 778 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/Utilities.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | #if os(macOS) 4 | import Carbon.HIToolbox 5 | 6 | 7 | extension String { 8 | /** 9 | Makes the string localizable. 10 | */ 11 | var localized: String { 12 | NSLocalizedString(self, bundle: .module, comment: self) 13 | } 14 | } 15 | 16 | 17 | extension Data { 18 | var toString: String? { String(data: self, encoding: .utf8) } 19 | } 20 | 21 | 22 | extension NSEvent { 23 | var isKeyEvent: Bool { type == .keyDown || type == .keyUp } 24 | } 25 | 26 | 27 | extension NSTextField { 28 | func hideCaret() { 29 | (currentEditor() as? NSTextView)?.insertionPointColor = .clear 30 | } 31 | 32 | func restoreCaret() { 33 | (currentEditor() as? NSTextView)?.insertionPointColor = .labelColor 34 | } 35 | } 36 | 37 | 38 | extension NSView { 39 | func focus() { 40 | window?.makeFirstResponder(self) 41 | } 42 | 43 | func blur() { 44 | window?.makeFirstResponder(nil) 45 | } 46 | } 47 | 48 | 49 | /** 50 | Listen to local events. 51 | 52 | - Important: Don't foret to call `.start()`. 53 | 54 | ``` 55 | eventMonitor = LocalEventMonitor(events: [.leftMouseDown, .rightMouseDown]) { event in 56 | // Do something 57 | 58 | return event 59 | }.start() 60 | ``` 61 | */ 62 | final class LocalEventMonitor { 63 | private let events: NSEvent.EventTypeMask 64 | private let callback: (NSEvent) -> NSEvent? 65 | private weak var monitor: AnyObject? 66 | 67 | init(events: NSEvent.EventTypeMask, callback: @escaping (NSEvent) -> NSEvent?) { 68 | self.events = events 69 | self.callback = callback 70 | } 71 | 72 | deinit { 73 | stop() 74 | } 75 | 76 | @discardableResult 77 | func start() -> Self { 78 | monitor = NSEvent.addLocalMonitorForEvents(matching: events, handler: callback) as AnyObject 79 | return self 80 | } 81 | 82 | func stop() { 83 | guard let monitor else { 84 | return 85 | } 86 | 87 | NSEvent.removeMonitor(monitor) 88 | } 89 | } 90 | 91 | 92 | final class RunLoopLocalEventMonitor { 93 | private let runLoopMode: RunLoop.Mode 94 | private let callback: (NSEvent) -> NSEvent? 95 | private let observer: CFRunLoopObserver 96 | 97 | init( 98 | events: NSEvent.EventTypeMask, 99 | runLoopMode: RunLoop.Mode, 100 | callback: @escaping (NSEvent) -> NSEvent? 101 | ) { 102 | self.runLoopMode = runLoopMode 103 | self.callback = callback 104 | 105 | self.observer = CFRunLoopObserverCreateWithHandler(nil, CFRunLoopActivity.beforeSources.rawValue, true, 0) { _, _ in 106 | // Pull all events from the queue and handle the ones matching the given types. 107 | // Non-matching events are left untouched, maintaining their order in the queue. 108 | 109 | var eventsToHandle = [NSEvent]() 110 | 111 | // Retrieve all events from the event queue to preserve their order (instead of using the `matching` parameter). 112 | while let eventToHandle = NSApp.nextEvent(matching: .any, until: nil, inMode: .default, dequeue: true) { 113 | eventsToHandle.append(eventToHandle) 114 | } 115 | 116 | // Iterate over the gathered events, instead of doing it directly in the `while` loop, to avoid potential infinite loops caused by re-retrieving undiscarded events. 117 | for eventToHandle in eventsToHandle { 118 | var handledEvent: NSEvent? 119 | 120 | if !events.contains(NSEvent.EventTypeMask(rawValue: 1 << eventToHandle.type.rawValue)) { 121 | handledEvent = eventToHandle 122 | } else if let callbackEvent = callback(eventToHandle) { 123 | handledEvent = callbackEvent 124 | } 125 | 126 | guard let handledEvent else { 127 | continue 128 | } 129 | 130 | NSApp.postEvent(handledEvent, atStart: false) 131 | } 132 | } 133 | } 134 | 135 | deinit { 136 | stop() 137 | } 138 | 139 | @discardableResult 140 | func start() -> Self { 141 | CFRunLoopAddObserver(RunLoop.current.getCFRunLoop(), observer, CFRunLoopMode(runLoopMode.rawValue as CFString)) 142 | return self 143 | } 144 | 145 | func stop() { 146 | CFRunLoopRemoveObserver(RunLoop.current.getCFRunLoop(), observer, CFRunLoopMode(runLoopMode.rawValue as CFString)) 147 | } 148 | } 149 | 150 | 151 | extension NSEvent { 152 | static var modifiers: ModifierFlags { 153 | modifierFlags 154 | .intersection(.deviceIndependentFlagsMask) 155 | // We remove `capsLock` as it shouldn't affect the modifiers. 156 | // We remove `numericPad` as arrow keys trigger it, use `event.specialKeys` instead. 157 | .subtracting([.capsLock, .numericPad]) 158 | } 159 | 160 | /** 161 | Real modifiers. 162 | 163 | - Note: Prefer this over `.modifierFlags`. 164 | 165 | ``` 166 | // Check if Command is one of possible more modifiers keys 167 | event.modifiers.contains(.command) 168 | 169 | // Check if Command is the only modifier key 170 | event.modifiers == .command 171 | 172 | // Check if Command and Shift are the only modifiers 173 | event.modifiers == [.command, .shift] 174 | ``` 175 | */ 176 | var modifiers: ModifierFlags { 177 | modifierFlags 178 | .intersection(.deviceIndependentFlagsMask) 179 | // We remove `capsLock` as it shouldn't affect the modifiers. 180 | // We remove `numericPad` as arrow keys trigger it, use `event.specialKeys` instead. 181 | .subtracting([.capsLock, .numericPad]) 182 | } 183 | } 184 | 185 | 186 | extension NSSearchField { 187 | /** 188 | Clear the search field. 189 | */ 190 | func clear() { 191 | (cell as? NSSearchFieldCell)?.cancelButtonCell?.performClick(self) 192 | } 193 | } 194 | 195 | 196 | extension NSAlert { 197 | /** 198 | Show an alert as a window-modal sheet, or as an app-modal (window-independent) alert if the window is `nil` or not given. 199 | */ 200 | @discardableResult 201 | static func showModal( 202 | for window: NSWindow? = nil, 203 | title: String, 204 | message: String? = nil, 205 | style: Style = .warning, 206 | icon: NSImage? = nil, 207 | buttonTitles: [String] = [] 208 | ) -> NSApplication.ModalResponse { 209 | NSAlert( 210 | title: title, 211 | message: message, 212 | style: style, 213 | icon: icon, 214 | buttonTitles: buttonTitles 215 | ).runModal(for: window) 216 | } 217 | 218 | convenience init( 219 | title: String, 220 | message: String? = nil, 221 | style: Style = .warning, 222 | icon: NSImage? = nil, 223 | buttonTitles: [String] = [] 224 | ) { 225 | self.init() 226 | self.messageText = title 227 | self.alertStyle = style 228 | self.icon = icon 229 | 230 | for buttonTitle in buttonTitles { 231 | addButton(withTitle: buttonTitle) 232 | } 233 | 234 | if let message { 235 | self.informativeText = message 236 | } 237 | } 238 | 239 | /** 240 | Runs the alert as a window-modal sheet, or as an app-modal (window-independent) alert if the window is `nil` or not given. 241 | */ 242 | @discardableResult 243 | func runModal(for window: NSWindow? = nil) -> NSApplication.ModalResponse { 244 | guard let window else { 245 | return runModal() 246 | } 247 | 248 | beginSheetModal(for: window) { returnCode in 249 | NSApp.stopModal(withCode: returnCode) 250 | } 251 | 252 | return NSApp.runModal(for: window) 253 | } 254 | } 255 | 256 | 257 | enum UnicodeSymbols { 258 | /** 259 | Represents the Function (Fn) key on the keybord. 260 | */ 261 | static let functionKey = "🌐\u{FE0E}" 262 | } 263 | 264 | 265 | extension NSEvent.ModifierFlags { 266 | // Not documented anywhere, but reverse-engineered by me. 267 | private static let functionKey = 1 << 17 // 131072 (0x20000) 268 | 269 | var carbon: Int { 270 | var modifierFlags = 0 271 | 272 | if contains(.control) { 273 | modifierFlags |= controlKey 274 | } 275 | 276 | if contains(.option) { 277 | modifierFlags |= optionKey 278 | } 279 | 280 | if contains(.shift) { 281 | modifierFlags |= shiftKey 282 | } 283 | 284 | if contains(.command) { 285 | modifierFlags |= cmdKey 286 | } 287 | 288 | if contains(.function) { 289 | modifierFlags |= Self.functionKey 290 | } 291 | 292 | return modifierFlags 293 | } 294 | 295 | init(carbon: Int) { 296 | self.init() 297 | 298 | if carbon & controlKey == controlKey { 299 | insert(.control) 300 | } 301 | 302 | if carbon & optionKey == optionKey { 303 | insert(.option) 304 | } 305 | 306 | if carbon & shiftKey == shiftKey { 307 | insert(.shift) 308 | } 309 | 310 | if carbon & cmdKey == cmdKey { 311 | insert(.command) 312 | } 313 | 314 | if carbon & Self.functionKey == Self.functionKey { 315 | insert(.function) 316 | } 317 | } 318 | } 319 | 320 | extension SwiftUI.EventModifiers { 321 | // `.function` is deprecated, so we use the raw value. 322 | fileprivate static let function_nonDeprecated = Self(rawValue: 64) 323 | } 324 | 325 | extension NSEvent.ModifierFlags { 326 | var toEventModifiers: SwiftUI.EventModifiers { 327 | var modifiers = SwiftUI.EventModifiers() 328 | 329 | if contains(.capsLock) { 330 | modifiers.insert(.capsLock) 331 | } 332 | 333 | if contains(.command) { 334 | modifiers.insert(.command) 335 | } 336 | 337 | if contains(.control) { 338 | modifiers.insert(.control) 339 | } 340 | 341 | if contains(.numericPad) { 342 | modifiers.insert(.numericPad) 343 | } 344 | 345 | if contains(.option) { 346 | modifiers.insert(.option) 347 | } 348 | 349 | if contains(.shift) { 350 | modifiers.insert(.shift) 351 | } 352 | 353 | if contains(.function) { 354 | modifiers.insert(.function_nonDeprecated) 355 | } 356 | 357 | return modifiers 358 | } 359 | } 360 | 361 | extension NSEvent.ModifierFlags { 362 | /** 363 | The string representation of the modifier flags. 364 | 365 | ``` 366 | print(NSEvent.ModifierFlags([.command, .shift])) 367 | //=> "⇧⌘" 368 | ``` 369 | */ 370 | var presentableDescription: String { 371 | var description = "" 372 | 373 | if contains(.control) { 374 | description += "⌃" 375 | } 376 | 377 | if contains(.option) { 378 | description += "⌥" 379 | } 380 | 381 | if contains(.shift) { 382 | description += "⇧" 383 | } 384 | 385 | if contains(.command) { 386 | description += "⌘" 387 | } 388 | 389 | if contains(.function) { 390 | description += UnicodeSymbols.functionKey 391 | } 392 | 393 | return description 394 | } 395 | } 396 | 397 | 398 | extension NSEvent.SpecialKey { 399 | static let functionKeys: Set = [ 400 | .f1, 401 | .f2, 402 | .f3, 403 | .f4, 404 | .f5, 405 | .f6, 406 | .f7, 407 | .f8, 408 | .f9, 409 | .f10, 410 | .f11, 411 | .f12, 412 | .f13, 413 | .f14, 414 | .f15, 415 | .f16, 416 | .f17, 417 | .f18, 418 | .f19, 419 | .f20, 420 | .f21, 421 | .f22, 422 | .f23, 423 | .f24, 424 | .f25, 425 | .f26, 426 | .f27, 427 | .f28, 428 | .f29, 429 | .f30, 430 | .f31, 431 | .f32, 432 | .f33, 433 | .f34, 434 | .f35 435 | ] 436 | 437 | var isFunctionKey: Bool { Self.functionKeys.contains(self) } 438 | } 439 | 440 | 441 | enum AssociationPolicy { 442 | case assign 443 | case retainNonatomic 444 | case copyNonatomic 445 | case retain 446 | case copy 447 | 448 | var rawValue: objc_AssociationPolicy { 449 | switch self { 450 | case .assign: 451 | .OBJC_ASSOCIATION_ASSIGN 452 | case .retainNonatomic: 453 | .OBJC_ASSOCIATION_RETAIN_NONATOMIC 454 | case .copyNonatomic: 455 | .OBJC_ASSOCIATION_COPY_NONATOMIC 456 | case .retain: 457 | .OBJC_ASSOCIATION_RETAIN 458 | case .copy: 459 | .OBJC_ASSOCIATION_COPY 460 | } 461 | } 462 | } 463 | 464 | final class ObjectAssociation { 465 | private let policy: AssociationPolicy 466 | 467 | init(policy: AssociationPolicy = .retainNonatomic) { 468 | self.policy = policy 469 | } 470 | 471 | subscript(index: AnyObject) -> T? { 472 | get { 473 | // Force-cast is fine here as we want it to fail loudly if we don't use the correct type. 474 | // swiftlint:disable:next force_cast 475 | objc_getAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque()) as! T? 476 | } 477 | set { 478 | objc_setAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque(), newValue, policy.rawValue) 479 | } 480 | } 481 | } 482 | 483 | 484 | extension HorizontalAlignment { 485 | private enum ControlAlignment: AlignmentID { 486 | static func defaultValue(in context: ViewDimensions) -> CGFloat { // swiftlint:disable:this no_cgfloat 487 | context[HorizontalAlignment.center] 488 | } 489 | } 490 | 491 | fileprivate static let controlAlignment = Self(ControlAlignment.self) 492 | } 493 | 494 | extension View { 495 | func formLabel(@ViewBuilder _ label: () -> some View) -> some View { 496 | HStack(alignment: .firstTextBaseline) { 497 | label() 498 | labelsHidden() 499 | .alignmentGuide(.controlAlignment) { $0[.leading] } 500 | } 501 | .alignmentGuide(.leading) { $0[.controlAlignment] } 502 | } 503 | } 504 | 505 | 506 | extension Dictionary { 507 | func hasKey(_ key: Key) -> Bool { 508 | index(forKey: key) != nil 509 | } 510 | } 511 | #endif 512 | 513 | 514 | @available(iOS 14.0, *) 515 | @available(macOS 11.0, *) 516 | extension KeyEquivalent { 517 | init?(unicodeScalarValue value: Int) { 518 | guard let character = Character(unicodeScalarValue: value) else { 519 | return nil 520 | } 521 | 522 | self = KeyEquivalent(character) 523 | } 524 | } 525 | 526 | 527 | extension Sequence where Element: Hashable { 528 | /** 529 | Convert a `Sequence` with `Hashable` elements to a `Set`. 530 | */ 531 | func toSet() -> Set { Set(self) } 532 | } 533 | 534 | 535 | extension Set { 536 | /** 537 | Convert a `Set` to an `Array`. 538 | */ 539 | func toArray() -> [Element] { Array(self) } 540 | } 541 | 542 | 543 | extension StringProtocol { 544 | func replacingPrefix(_ prefix: String, with replacement: String) -> String { 545 | guard hasPrefix(prefix) else { 546 | return String(self) 547 | } 548 | 549 | return replacement + dropFirst(prefix.count) 550 | } 551 | } 552 | 553 | extension Character { 554 | init?(unicodeScalarValue value: Int) { 555 | guard let content = UnicodeScalar(value) else { 556 | return nil 557 | } 558 | 559 | self = Character(content) 560 | } 561 | } 562 | -------------------------------------------------------------------------------- /Sources/KeyboardShortcuts/ViewModifiers.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import SwiftUI 3 | 4 | @available(macOS 12, *) 5 | extension View { 6 | /** 7 | Renamed to `onGlobalKeyboardShortcut`. 8 | */ 9 | @available(*, deprecated, renamed: "onGlobalKeyboardShortcut") 10 | public func onKeyboardShortcut( 11 | _ shortcut: KeyboardShortcuts.Name, 12 | perform: @escaping (KeyboardShortcuts.EventType) -> Void 13 | ) -> some View { 14 | task { 15 | for await eventType in KeyboardShortcuts.events(for: shortcut) { 16 | perform(eventType) 17 | } 18 | } 19 | } 20 | 21 | /** 22 | Renamed to `onGlobalKeyboardShortcut`. 23 | */ 24 | @available(*, deprecated, renamed: "onGlobalKeyboardShortcut") 25 | public func onKeyboardShortcut( 26 | _ shortcut: KeyboardShortcuts.Name, 27 | type: KeyboardShortcuts.EventType, 28 | perform: @escaping () -> Void 29 | ) -> some View { 30 | task { 31 | for await _ in KeyboardShortcuts.events(type, for: shortcut) { 32 | perform() 33 | } 34 | } 35 | } 36 | } 37 | 38 | @available(macOS 12, *) 39 | extension View { 40 | /** 41 | Register a listener for keyboard shortcut events with the given name. 42 | 43 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do. 44 | 45 | The listener will stop automatically when the view disappears. 46 | 47 | - Note: This method is not affected by `.removeAllHandlers()`. 48 | */ 49 | public func onGlobalKeyboardShortcut( 50 | _ shortcut: KeyboardShortcuts.Name, 51 | perform: @escaping (KeyboardShortcuts.EventType) -> Void 52 | ) -> some View { 53 | task { 54 | for await eventType in KeyboardShortcuts.events(for: shortcut) { 55 | perform(eventType) 56 | } 57 | } 58 | } 59 | 60 | /** 61 | Register a listener for keyboard shortcut events with the given name and type. 62 | 63 | You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do. 64 | 65 | The listener will stop automatically when the view disappears. 66 | 67 | - Note: This method is not affected by `.removeAllHandlers()`. 68 | */ 69 | public func onGlobalKeyboardShortcut( 70 | _ shortcut: KeyboardShortcuts.Name, 71 | type: KeyboardShortcuts.EventType, 72 | perform: @escaping () -> Void 73 | ) -> some View { 74 | task { 75 | for await _ in KeyboardShortcuts.events(type, for: shortcut) { 76 | perform() 77 | } 78 | } 79 | } 80 | } 81 | 82 | @available(macOS 12.3, *) 83 | extension View { 84 | /** 85 | Associates a global keyboard shortcut with a control. 86 | 87 | This is mostly useful to have the keyboard shortcut show for a `Button` in a `Menu` or `MenuBarExtra`. 88 | 89 | It does not trigger the control's action. 90 | 91 | - Important: Do not use it in a `CommandGroup` as the shortcut recorder will think the shortcut is already taken. It does remove the shortcut while the recorder is active, but because of a bug in macOS 15, the state is not reflected correctly in the underlying menu item. 92 | */ 93 | public func globalKeyboardShortcut(_ name: KeyboardShortcuts.Name) -> some View { 94 | modifier(GlobalKeyboardShortcutViewModifier(name: name)) 95 | } 96 | } 97 | 98 | @available(macOS 12.3, *) 99 | private struct GlobalKeyboardShortcutViewModifier: ViewModifier { 100 | @State private var isRecorderActive = false 101 | @State private var triggerRefresh = false 102 | 103 | let name: KeyboardShortcuts.Name 104 | 105 | func body(content: Content) -> some View { 106 | content 107 | .keyboardShortcut(isRecorderActive ? nil : name.shortcut?.toSwiftUI) 108 | .id(triggerRefresh) 109 | .onReceive(NotificationCenter.default.publisher(for: .shortcutByNameDidChange)) { 110 | guard $0.userInfo?["name"] as? KeyboardShortcuts.Name == name else { 111 | return 112 | } 113 | 114 | triggerRefresh.toggle() 115 | } 116 | .onReceive(NotificationCenter.default.publisher(for: .recorderActiveStatusDidChange)) { 117 | isRecorderActive = $0.userInfo?["isActive"] as? Bool ?? false 118 | } 119 | } 120 | } 121 | #endif 122 | -------------------------------------------------------------------------------- /Tests/KeyboardShortcutsTests/KeyboardShortcutsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import KeyboardShortcuts 3 | 4 | final class KeyboardShortcutsTests: XCTestCase { 5 | // TODO: Add more tests. 6 | 7 | override func setUpWithError() throws { 8 | UserDefaults.standard.removeAll() 9 | } 10 | 11 | func testSetShortcutAndReset() throws { 12 | let defaultShortcut = KeyboardShortcuts.Shortcut(.c) 13 | let shortcut1 = KeyboardShortcuts.Shortcut(.a) 14 | let shortcut2 = KeyboardShortcuts.Shortcut(.b) 15 | 16 | let shortcutName1 = KeyboardShortcuts.Name("testSetShortcutAndReset1") 17 | let shortcutName2 = KeyboardShortcuts.Name("testSetShortcutAndReset2", default: defaultShortcut) 18 | 19 | KeyboardShortcuts.setShortcut(shortcut1, for: shortcutName1) 20 | KeyboardShortcuts.setShortcut(shortcut2, for: shortcutName2) 21 | 22 | XCTAssertEqual(KeyboardShortcuts.getShortcut(for: shortcutName1), shortcut1) 23 | XCTAssertEqual(KeyboardShortcuts.getShortcut(for: shortcutName2), shortcut2) 24 | 25 | KeyboardShortcuts.reset(shortcutName1, shortcutName2) 26 | 27 | XCTAssertNil(KeyboardShortcuts.getShortcut(for: shortcutName1)) 28 | XCTAssertEqual(KeyboardShortcuts.getShortcut(for: shortcutName2), defaultShortcut) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/KeyboardShortcutsTests/Utilities.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension UserDefaults { 4 | /** 5 | Remove all entries. 6 | 7 | - Note: This only removes user-defined entries. System-defined entries will remain. 8 | */ 9 | public func removeAll() { 10 | for key in dictionaryRepresentation().keys { 11 | removeObject(forKey: key) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/KeyboardShortcuts/2927f7037492c193111e753c41fc5588b3317007/logo-dark.png -------------------------------------------------------------------------------- /logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/KeyboardShortcuts/2927f7037492c193111e753c41fc5588b3317007/logo-light.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 | 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.15+ 16 | 17 | ## Install 18 | 19 | Add `https://github.com/sindresorhus/KeyboardShortcuts` in the [“Swift Package Manager” tab in Xcode](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app). 20 | 21 | ## Usage 22 | 23 | First, register a name for the keyboard shortcut. 24 | 25 | `Constants.swift` 26 | 27 | ```swift 28 | import KeyboardShortcuts 29 | 30 | extension KeyboardShortcuts.Name { 31 | static let toggleUnicornMode = Self("toggleUnicornMode") 32 | } 33 | ``` 34 | 35 | You can then refer to this strongly-typed name in other places. 36 | 37 | You will want to make a view where the user can choose a keyboard shortcut. 38 | 39 | `SettingsScreen.swift` 40 | 41 | ```swift 42 | import SwiftUI 43 | import KeyboardShortcuts 44 | 45 | struct SettingsScreen: View { 46 | var body: some View { 47 | Form { 48 | KeyboardShortcuts.Recorder("Toggle Unicorn Mode:", name: .toggleUnicornMode) 49 | } 50 | } 51 | } 52 | ``` 53 | 54 | *There's also [support for Cocoa](#cocoa) instead of SwiftUI.* 55 | 56 | `KeyboardShortcuts.Recorder` takes care of storing the keyboard shortcut in `UserDefaults` and also warning the user if the chosen keyboard shortcut is already used by the system or the app's main menu. 57 | 58 | Add a listener for when the user presses their chosen keyboard shortcut. 59 | 60 | `App.swift` 61 | 62 | ```swift 63 | import SwiftUI 64 | import KeyboardShortcuts 65 | 66 | @main 67 | struct YourApp: App { 68 | @State private var appState = AppState() 69 | 70 | var body: some Scene { 71 | WindowGroup { 72 | // … 73 | } 74 | Settings { 75 | SettingsScreen() 76 | } 77 | } 78 | } 79 | 80 | @MainActor 81 | @Observable 82 | final class AppState { 83 | init() { 84 | KeyboardShortcuts.onKeyUp(for: .toggleUnicornMode) { [self] in 85 | isUnicornMode.toggle() 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | *You can also listen to key down with `.onKeyDown()`* 92 | 93 | **That's all! ✨** 94 | 95 | You can find a complete example in the “Example” directory. 96 | 97 | You can also find a [real-world example](https://github.com/sindresorhus/Plash/blob/b348a62645a873abba8dc11ff0fb8fe423419411/Plash/PreferencesView.swift#L121-L130) in my Plash app. 98 | 99 | #### Cocoa 100 | 101 | Using [`KeyboardShortcuts.RecorderCocoa`](Sources/KeyboardShortcuts/RecorderCocoa.swift) instead of `KeyboardShortcuts.Recorder`: 102 | 103 | ```swift 104 | import AppKit 105 | import KeyboardShortcuts 106 | 107 | final class SettingsViewController: NSViewController { 108 | override func loadView() { 109 | view = NSView() 110 | 111 | let recorder = KeyboardShortcuts.RecorderCocoa(for: .toggleUnicornMode) 112 | view.addSubview(recorder) 113 | } 114 | } 115 | ``` 116 | 117 | ## Localization 118 | 119 | This package supports [localizations](/Sources/KeyboardShortcuts/Localization). PR welcome for more! 120 | 121 | 1. Fork the repo. 122 | 2. Create a directory that has a name that uses an [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language code and optional designators, followed by the `.lproj` suffix. [More here.](https://developer.apple.com/documentation/swift_packages/localizing_package_resources) 123 | 3. Create a file named `Localizable.strings` under the new language directory and then copy the contents of `KeyboardShortcuts/Localization/en.lproj/Localizable.strings` to the new file that you just created. 124 | 4. Localize and make sure to review your localization multiple times. Check for typos. 125 | 5. Try to find someone that speaks your language to review the translation. 126 | 6. Submit a PR. 127 | 128 | ## API 129 | 130 | [See the API docs.](https://swiftpackageindex.com/sindresorhus/KeyboardShortcuts/documentation/keyboardshortcuts/keyboardshortcuts) 131 | 132 | ## Tips 133 | 134 | #### Show a recorded keyboard shortcut in an `NSMenuItem` 135 | 136 | 137 | 138 | See [`NSMenuItem#setShortcut`](https://github.com/sindresorhus/KeyboardShortcuts/blob/0dcedd56994d871f243f3d9c76590bfd9f8aba69/Sources/KeyboardShortcuts/NSMenuItem%2B%2B.swift#L14-L41). 139 | 140 | #### Dynamic keyboard shortcuts 141 | 142 | Your app might need to support keyboard shortcuts for user-defined actions. Normally, you would statically register the keyboard shortcuts upfront in `extension KeyboardShortcuts.Name {}`. However, this is not a requirement. It's only for convenience so that you can use dot-syntax when calling various APIs (for example, `.onKeyDown(.unicornMode) {}`). You can create `KeyboardShortcut.Name`'s dynamically and store them yourself. You can see this in action in the example project. 143 | 144 | #### Default keyboard shortcuts 145 | 146 | Setting a default keyboard shortcut can be useful if you're migrating from a different package or just making something for yourself. However, please do not set this for a publicly distributed app. Users find it annoying when random apps steal their existing keyboard shortcuts. It’s generally better to show a welcome screen on the first app launch that lets the user set the shortcut. 147 | 148 | ```swift 149 | import KeyboardShortcuts 150 | 151 | extension KeyboardShortcuts.Name { 152 | static let toggleUnicornMode = Self("toggleUnicornMode", default: .init(.k, modifiers: [.command, .option])) 153 | } 154 | ``` 155 | 156 | #### Get all keyboard shortcuts 157 | 158 | To get all the keyboard shortcut `Name`'s, conform `KeyboardShortcuts.Name` to `CaseIterable`. 159 | 160 | ```swift 161 | import KeyboardShortcuts 162 | 163 | extension KeyboardShortcuts.Name { 164 | static let foo = Self("foo") 165 | static let bar = Self("bar") 166 | } 167 | 168 | extension KeyboardShortcuts.Name: CaseIterable { 169 | public static let allCases: [Self] = [ 170 | .foo, 171 | .bar 172 | ] 173 | } 174 | 175 | // … 176 | 177 | print(KeyboardShortcuts.Name.allCases) 178 | ``` 179 | 180 | And to get all the `Name`'s with a set keyboard shortcut: 181 | 182 | ```swift 183 | print(KeyboardShortcuts.Name.allCases.filter { $0.shortcut != nil }) 184 | ``` 185 | 186 | ## FAQ 187 | 188 | #### How is it different from [`MASShortcut`](https://github.com/shpakovski/MASShortcut)? 189 | 190 | This package: 191 | - Written in Swift with a swifty API. 192 | - More native-looking UI component. 193 | - SwiftUI component included. 194 | - Support for listening to key down, not just key up. 195 | - Swift Package Manager support. 196 | - Connect a shortcut to an `NSMenuItem`. 197 | - Works when [`NSMenu` is open](https://github.com/sindresorhus/KeyboardShortcuts/issues/1) (e.g. menu bar apps). 198 | 199 | `MASShortcut`: 200 | - More mature. 201 | - More localizations. 202 | 203 | #### How is it different from [`HotKey`](https://github.com/soffes/HotKey)? 204 | 205 | `HotKey` is good for adding hard-coded keyboard shortcuts, but it doesn't provide any UI component for the user to choose their own keyboard shortcuts. 206 | 207 | #### Why is this package importing `Carbon`? Isn't that deprecated? 208 | 209 | Most of the Carbon APIs were deprecated years ago, but there are some left that Apple never shipped modern replacements for. This includes registering global keyboard shortcuts. However, you should not need to worry about this. Apple will for sure ship new APIs before deprecating the Carbon APIs used here. 210 | 211 | #### Does this package cause any permission dialogs? 212 | 213 | No. 214 | 215 | #### How can I add an app-specific keyboard shortcut that is only active when the app is? 216 | 217 | That is outside the scope of this package. You can either use [`NSEvent.addLocalMonitorForEvents`](https://developer.apple.com/documentation/appkit/nsevent/1534971-addlocalmonitorforevents), [`NSMenuItem` with keyboard shortcut](https://developer.apple.com/documentation/appkit/nsmenuitem/2880316-allowskeyequivalentwhenhidden) (it can even be hidden), or SwiftUI's [`View#keyboardShortcut()` modifier](https://developer.apple.com/documentation/swiftui/form/keyboardshortcut(_:)). 218 | 219 | #### Does it support media keys? 220 | 221 | No, since it would not work for sandboxed apps. If your app is not sandboxed, you can use [`MediaKeyTap`](https://github.com/nhurden/MediaKeyTap). 222 | 223 | #### Can you support CocoaPods or Carthage? 224 | 225 | No. However, there is nothing stopping you from using Swift Package Manager for just this package even if you normally use CocoaPods or Carthage. 226 | 227 | ## Related 228 | 229 | - [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults 230 | - [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Add "Launch at Login" functionality to your macOS app 231 | - [More…](https://github.com/search?q=user%3Asindresorhus+language%3Aswift+archived%3Afalse&type=repositories) 232 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/KeyboardShortcuts/2927f7037492c193111e753c41fc5588b3317007/screenshot.png --------------------------------------------------------------------------------