diff --git a/KeyboardShortcuts.xcodeproj/project.pbxproj b/KeyboardShortcuts.xcodeproj/project.pbxproj index ced1893c..9e04b861 100644 --- a/KeyboardShortcuts.xcodeproj/project.pbxproj +++ b/KeyboardShortcuts.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ E38103FE246449180023E9A8 /* Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38103FD246449180023E9A8 /* Name.swift */; }; + E3AD497024705C7600F51C0D /* NSMenuItem++.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AD496F24705C7600F51C0D /* NSMenuItem++.swift */; }; E3BF5627245C23840024D9BF /* Recorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3BF5626245C23840024D9BF /* Recorder.swift */; }; E3BF5629245C24450024D9BF /* RecorderCocoa.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3BF5628245C24450024D9BF /* RecorderCocoa.swift */; }; E3BF562B245C28BD0024D9BF /* Shortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3BF562A245C28BD0024D9BF /* Shortcut.swift */; }; @@ -50,6 +51,7 @@ /* Begin PBXFileReference section */ E38103FD246449180023E9A8 /* Name.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Name.swift; sourceTree = ""; usesTabs = 1; }; + E3AD496F24705C7600F51C0D /* NSMenuItem++.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = "NSMenuItem++.swift"; sourceTree = ""; usesTabs = 1; }; E3BF5626245C23840024D9BF /* Recorder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Recorder.swift; sourceTree = ""; usesTabs = 1; }; E3BF5628245C24450024D9BF /* RecorderCocoa.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = RecorderCocoa.swift; sourceTree = ""; usesTabs = 1; }; E3BF562A245C28BD0024D9BF /* Shortcut.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Shortcut.swift; sourceTree = ""; usesTabs = 1; }; @@ -155,6 +157,7 @@ E3BF562A245C28BD0024D9BF /* Shortcut.swift */, E3BF5628245C24450024D9BF /* RecorderCocoa.swift */, E3BF5626245C23840024D9BF /* Recorder.swift */, + E3AD496F24705C7600F51C0D /* NSMenuItem++.swift */, OBJ_10 /* util.swift */, ); name = KeyboardShortcuts; @@ -318,6 +321,7 @@ OBJ_23 /* KeyboardShortcuts.swift in Sources */, E3BF562D245C29C30024D9BF /* CarbonKeyboardShortcuts.swift in Sources */, OBJ_24 /* util.swift in Sources */, + E3AD497024705C7600F51C0D /* NSMenuItem++.swift in Sources */, E3BF5629245C24450024D9BF /* RecorderCocoa.swift in Sources */, E3BF564C245C34B30024D9BF /* Key.swift in Sources */, E3BF5627245C23840024D9BF /* Recorder.swift in Sources */, diff --git a/KeyboardShortcutsExample/AppDelegate.swift b/KeyboardShortcutsExample/AppDelegate.swift index b8ee573b..cef828af 100644 --- a/KeyboardShortcutsExample/AppDelegate.swift +++ b/KeyboardShortcutsExample/AppDelegate.swift @@ -1,5 +1,6 @@ import Cocoa import SwiftUI +import KeyboardShortcuts @NSApplicationMain final class AppDelegate: NSObject, NSApplicationDelegate { @@ -25,5 +26,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate { window.setFrameAutosaveName("Main Window") window.contentView = NSHostingView(rootView: contentView) window.makeKeyAndOrderFront(nil) + + createMenus() + } + + func createMenus() { + let testMenuItem = NSMenuItem() + NSApp.mainMenu?.addItem(testMenuItem) + + let testMenu = NSMenu() + testMenu.title = "Test" + testMenuItem.submenu = testMenu + + let shortcut1 = NSMenuItem() + shortcut1.title = "Shortcut 1" + shortcut1.action = #selector(shortcutAction1) + shortcut1.setShortcut(for: .testShortcut1) + testMenu.addItem(shortcut1) + + let shortcut2 = NSMenuItem() + shortcut2.title = "Shortcut 2" + shortcut2.action = #selector(shortcutAction2) + shortcut2.setShortcut(for: .testShortcut2) + testMenu.addItem(shortcut2) + } + + @objc + func shortcutAction1(_ sender: NSMenuItem) { + let alert = NSAlert() + alert.messageText = "Shortcut 1 menu item action triggered!" + alert.runModal() + } + + @objc + func shortcutAction2(_ sender: NSMenuItem) { + let alert = NSAlert() + alert.messageText = "Shortcut 2 menu item action triggered!" + alert.runModal() } } diff --git a/Sources/KeyboardShortcuts/KeyboardShortcuts.swift b/Sources/KeyboardShortcuts/KeyboardShortcuts.swift index ccfdd7fa..5a5a4f8b 100644 --- a/Sources/KeyboardShortcuts/KeyboardShortcuts.swift +++ b/Sources/KeyboardShortcuts/KeyboardShortcuts.swift @@ -175,6 +175,11 @@ public final class KeyboardShortcuts { private static func userDefaultsKey(for shortcutName: Name) -> String { "\(userDefaultsPrefix)\(shortcutName.rawValue)" } + static func userDefaultsDidChange(name: Name) { + // TODO: Use proper UserDefaults observation instead of this. + NotificationCenter.default.post(name: .shortcutByNameDidChange, object: nil, userInfo: ["name": name]) + } + // TODO: Should these be on `Shortcut` instead? static func userDefaultsGet(name: Name) -> Shortcut? { guard @@ -194,6 +199,7 @@ public final class KeyboardShortcuts { UserDefaults.standard.set(encoded, forKey: userDefaultsKey(for: name)) register(shortcut) + userDefaultsDidChange(name: name) } static func userDefaultsRemove(name: Name) { @@ -203,5 +209,10 @@ public final class KeyboardShortcuts { UserDefaults.standard.removeObject(forKey: userDefaultsKey(for: name)) unregister(shortcut) + userDefaultsDidChange(name: name) } } + +extension Notification.Name { + static let shortcutByNameDidChange = Self("KeyboardShortcuts_shortcutByNameDidChange") +} diff --git a/Sources/KeyboardShortcuts/NSMenuItem++.swift b/Sources/KeyboardShortcuts/NSMenuItem++.swift new file mode 100644 index 00000000..325e618b --- /dev/null +++ b/Sources/KeyboardShortcuts/NSMenuItem++.swift @@ -0,0 +1,72 @@ +import Cocoa + +extension NSMenuItem { + private struct AssociatedKeys { + static let observer = ObjectAssociation() + } + + // TODO: Make this a getter/setter. We must first add the ability to create a `Shortcut` from a `keyEquivalent`. + /** + Show a recorded keyboard shortcut in a `NSMenuItem`. + + The menu item will automatically be kept up to date with changes to the keyboard shortcut. + + Pass in `nil` to clear the keyboard shortcut. + + This method overrides `.keyEquivalent` and `.keyEquivalentModifierMask`. + + ``` + import Cocoa + import KeyboardShortcuts + + extension KeyboardShortcuts.Name { + static let toggleUnicornMode = Name("toggleUnicornMode") + } + + // … `Recorder` logic for recording the keyboard shortcut … + + let menuItem = NSMenuItem() + menuItem.title = "Toggle Unicorn Mode" + menuItem.setShortcut(for: .toggleUnicornMode) + ``` + + 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. + + - 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`. + */ + public func setShortcut(for name: KeyboardShortcuts.Name?) { + func clear() { + keyEquivalent = "" + keyEquivalentModifierMask = [] + } + + guard let name = name else { + clear() + AssociatedKeys.observer[self] = nil + return + } + + func set() { + guard let shortcut = KeyboardShortcuts.Shortcut(name: name) else { + clear() + return + } + + keyEquivalent = shortcut.keyEquivalent + keyEquivalentModifierMask = shortcut.modifiers + } + + set() + + AssociatedKeys.observer[self] = NotificationCenter.default.addObserver(forName: .shortcutByNameDidChange, object: nil, queue: nil) { notification in + guard + let nameInNotification = notification.userInfo?["name"] as? KeyboardShortcuts.Name, + nameInNotification == name + else { + return + } + + set() + } + } +} diff --git a/Sources/KeyboardShortcuts/RecorderCocoa.swift b/Sources/KeyboardShortcuts/RecorderCocoa.swift index 3ce31a02..603e441a 100644 --- a/Sources/KeyboardShortcuts/RecorderCocoa.swift +++ b/Sources/KeyboardShortcuts/RecorderCocoa.swift @@ -213,13 +213,5 @@ extension KeyboardShortcuts { return shouldBecomeFirstResponder } - - private func focus() { - window?.makeFirstResponder(self) - } - - private func blur() { - window?.makeFirstResponder(nil) - } } } diff --git a/Sources/KeyboardShortcuts/Shortcut.swift b/Sources/KeyboardShortcuts/Shortcut.swift index 4bdb1cc1..fa9af75c 100644 --- a/Sources/KeyboardShortcuts/Shortcut.swift +++ b/Sources/KeyboardShortcuts/Shortcut.swift @@ -16,13 +16,13 @@ extension KeyboardShortcuts { public var key: Key? { Key(rawValue: carbonKeyCode) } public var modifiers: NSEvent.ModifierFlags { NSEvent.ModifierFlags(carbon: carbonModifiers) } - /// Create a shortcut from a key code number and modifier code. + /// Initialize from a key code number and modifier code. public init(carbonKeyCode: Int, carbonModifiers: Int = 0) { self.carbonKeyCode = carbonKeyCode self.carbonModifiers = Self.normalizeModifiers(carbonModifiers) } - /// Create a keyboard shortcut from a strongly-typed key and modifiers. + /// Initialize from a strongly-typed key and modifiers. public init(_ key: Key, modifiers: NSEvent.ModifierFlags = []) { self.init( carbonKeyCode: key.rawValue, @@ -30,7 +30,7 @@ extension KeyboardShortcuts { ) } - /// Create a keyboard shortcut from a key event. + /// Initialize from a key event. public init?(event: NSEvent) { guard event.isKeyEvent else { return nil @@ -41,6 +41,15 @@ extension KeyboardShortcuts { carbonModifiers: event.modifierFlags.carbon ) } + + /// Initialize from a keyboard shortcut stored by `Recorder` or `RecorderCocoa`. + public init?(name: Name) { + guard let shortcut = userDefaultsGet(name: name) else { + return nil + } + + self = shortcut + } } } @@ -122,7 +131,35 @@ private var keyToCharacterMapping: [KeyboardShortcuts.Key: String] = [ .f20: "F20" ] -extension KeyboardShortcuts.Shortcut: CustomStringConvertible { +private func stringFromKeyCode(_ keyCode: Int) -> String { + String(format: "%C", keyCode) +} + +private var keyToKeyEquivalentString: [KeyboardShortcuts.Key: String] = [ + .space: stringFromKeyCode(0x20), + .f1: stringFromKeyCode(NSF1FunctionKey), + .f2: stringFromKeyCode(NSF2FunctionKey), + .f3: stringFromKeyCode(NSF3FunctionKey), + .f4: stringFromKeyCode(NSF4FunctionKey), + .f5: stringFromKeyCode(NSF5FunctionKey), + .f6: stringFromKeyCode(NSF6FunctionKey), + .f7: stringFromKeyCode(NSF7FunctionKey), + .f8: stringFromKeyCode(NSF8FunctionKey), + .f9: stringFromKeyCode(NSF9FunctionKey), + .f10: stringFromKeyCode(NSF10FunctionKey), + .f11: stringFromKeyCode(NSF11FunctionKey), + .f12: stringFromKeyCode(NSF12FunctionKey), + .f13: stringFromKeyCode(NSF13FunctionKey), + .f14: stringFromKeyCode(NSF14FunctionKey), + .f15: stringFromKeyCode(NSF15FunctionKey), + .f16: stringFromKeyCode(NSF16FunctionKey), + .f17: stringFromKeyCode(NSF17FunctionKey), + .f18: stringFromKeyCode(NSF18FunctionKey), + .f19: stringFromKeyCode(NSF19FunctionKey), + .f20: stringFromKeyCode(NSF20FunctionKey) +] + +extension KeyboardShortcuts.Shortcut { fileprivate func keyToCharacter() -> String? { // Some characters cannot be automatically translated. if @@ -161,6 +198,31 @@ extension KeyboardShortcuts.Shortcut: CustomStringConvertible { return String(utf16CodeUnits: characters, count: length) } + // This can be exposed if anyone needs it, but I prefer to keep the API surface small for now. + /** + This can be used to show the keyboard shortcut in a `NSMenuItem` by assigning it to `NSMenuItem#keyEquivalent`. + + - Note: Don't forget to also pass `.modifiers` to `NSMenuItem#keyEquivalentModifierMask`. + */ + var keyEquivalent: String { + let keyString = keyToCharacter() ?? "" + + guard keyString.count <= 1 else { + guard + let key = self.key, + let string = keyToKeyEquivalentString[key] + else { + return "" + } + + return string + } + + return keyString + } +} + +extension KeyboardShortcuts.Shortcut: CustomStringConvertible { /** The string representation of the keyboard shortcut. diff --git a/Sources/KeyboardShortcuts/util.swift b/Sources/KeyboardShortcuts/util.swift index a47918db..5d3db376 100644 --- a/Sources/KeyboardShortcuts/util.swift +++ b/Sources/KeyboardShortcuts/util.swift @@ -19,6 +19,17 @@ extension NSTextField { } +extension NSView { + func focus() { + window?.makeFirstResponder(self) + } + + func blur() { + window?.makeFirstResponder(nil) + } +} + + /** Listen to local events. @@ -274,3 +285,46 @@ extension NSEvent.SpecialKey { var isFunctionKey: Bool { Self.functionKeys.contains(self) } } + + +enum AssociationPolicy { + case assign + case retainNonatomic + case copyNonatomic + case retain + case copy + + var rawValue: objc_AssociationPolicy { + switch self { + case .assign: + return .OBJC_ASSOCIATION_ASSIGN + case .retainNonatomic: + return .OBJC_ASSOCIATION_RETAIN_NONATOMIC + case .copyNonatomic: + return .OBJC_ASSOCIATION_COPY_NONATOMIC + case .retain: + return .OBJC_ASSOCIATION_RETAIN + case .copy: + return .OBJC_ASSOCIATION_COPY + } + } +} + +final class ObjectAssociation { + private let policy: AssociationPolicy + + init(policy: AssociationPolicy = .retainNonatomic) { + self.policy = policy + } + + subscript(index: AnyObject) -> T? { + get { + // Force-cast is fine here as we want it to fail loudly if we don't use the correct type. + // swiftlint:disable:next force_cast + objc_getAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque()) as! T? + } + set { + objc_setAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque(), newValue, policy.rawValue) + } + } +} diff --git a/readme.md b/readme.md index d8112192..0ee305b4 100644 --- a/readme.md +++ b/readme.md @@ -17,11 +17,9 @@ macOS 10.11+ #### Swift Package Manager -```swift -.package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "0.1.1") -``` +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). -You need to set the build setting “Other Linker Flags” to `-weak_framework Combine` to work around [this Xcode bug](https://github.com/feedback-assistant/reports/issues/44). +You also need to set the build setting “Other Linker Flags” to `-weak_framework Combine` to work around [this Xcode bug](https://github.com/feedback-assistant/reports/issues/44). #### Carthage @@ -98,6 +96,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { You can find a complete example by opening `KeyboardShortcuts.xcodeproj` and then running the `KeyboardShortcutsExample` target. +You can also find a [real-world example](https://github.com/sindresorhus/Plash/blob/b348a62645a873abba8dc11ff0fb8fe423419411/Plash/PreferencesView.swift#L121-L130) in my Plash app. + #### Cocoa Use [`KeyboardShortcuts.RecorderCocoa`](Sources/KeyboardShortcuts/RecorderCocoa.swift) instead of `KeyboardShortcuts.Recorder`. @@ -120,6 +120,12 @@ final class PreferencesViewController: NSViewController { [See the API docs.](https://sindresorhus.com/KeyboardShortcuts/Classes/KeyboardShortcuts.html) +## Tips + +#### Show a recorded keyboard shortcut in a `NSMenuItem` + +See [`NSMenuItem#setShortcut`](https://sindresorhus.com/KeyboardShortcuts/Extensions/NSMenuItem.html). + ## FAQ #### How is it different from [`MASShortcut`](https://github.com/shpakovski/MASShortcut)?