Skip to content

Commit

Permalink
Add method to show a keyboard shortcut in NSMenuItem (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
sindresorhus committed May 19, 2020
1 parent 0419330 commit 9c0427a
Show file tree
Hide file tree
Showing 8 changed files with 255 additions and 16 deletions.
4 changes: 4 additions & 0 deletions KeyboardShortcuts.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -50,6 +51,7 @@

/* Begin PBXFileReference section */
E38103FD246449180023E9A8 /* Name.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Name.swift; sourceTree = "<group>"; usesTabs = 1; };
E3AD496F24705C7600F51C0D /* NSMenuItem++.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = "NSMenuItem++.swift"; sourceTree = "<group>"; usesTabs = 1; };
E3BF5626245C23840024D9BF /* Recorder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Recorder.swift; sourceTree = "<group>"; usesTabs = 1; };
E3BF5628245C24450024D9BF /* RecorderCocoa.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = RecorderCocoa.swift; sourceTree = "<group>"; usesTabs = 1; };
E3BF562A245C28BD0024D9BF /* Shortcut.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Shortcut.swift; sourceTree = "<group>"; usesTabs = 1; };
Expand Down Expand Up @@ -155,6 +157,7 @@
E3BF562A245C28BD0024D9BF /* Shortcut.swift */,
E3BF5628245C24450024D9BF /* RecorderCocoa.swift */,
E3BF5626245C23840024D9BF /* Recorder.swift */,
E3AD496F24705C7600F51C0D /* NSMenuItem++.swift */,
OBJ_10 /* util.swift */,
);
name = KeyboardShortcuts;
Expand Down Expand Up @@ -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 */,
Expand Down
38 changes: 38 additions & 0 deletions KeyboardShortcutsExample/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Cocoa
import SwiftUI
import KeyboardShortcuts

@NSApplicationMain
final class AppDelegate: NSObject, NSApplicationDelegate {
Expand All @@ -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()
}
}
11 changes: 11 additions & 0 deletions Sources/KeyboardShortcuts/KeyboardShortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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")
}
72 changes: 72 additions & 0 deletions Sources/KeyboardShortcuts/NSMenuItem++.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Cocoa

extension NSMenuItem {
private struct AssociatedKeys {
static let observer = ObjectAssociation<NSObjectProtocol>()
}

// 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()
}
}
}
8 changes: 0 additions & 8 deletions Sources/KeyboardShortcuts/RecorderCocoa.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,13 +213,5 @@ extension KeyboardShortcuts {

return shouldBecomeFirstResponder
}

private func focus() {
window?.makeFirstResponder(self)
}

private func blur() {
window?.makeFirstResponder(nil)
}
}
}
70 changes: 66 additions & 4 deletions Sources/KeyboardShortcuts/Shortcut.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,21 @@ 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,
carbonModifiers: modifiers.carbon
)
}

/// Create a keyboard shortcut from a key event.
/// Initialize from a key event.
public init?(event: NSEvent) {
guard event.isKeyEvent else {
return nil
Expand All @@ -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
}
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions Sources/KeyboardShortcuts/util.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ extension NSTextField {
}


extension NSView {
func focus() {
window?.makeFirstResponder(self)
}

func blur() {
window?.makeFirstResponder(nil)
}
}


/**
Listen to local events.
Expand Down Expand Up @@ -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<T: Any> {
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)
}
}
}
Loading

0 comments on commit 9c0427a

Please sign in to comment.