-
-
Notifications
You must be signed in to change notification settings - Fork 161
/
Shortcut.swift
326 lines (283 loc) · 8.61 KB
/
Shortcut.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
import AppKit
import Carbon.HIToolbox
extension KeyboardShortcuts {
/**
A keyboard shortcut.
*/
public struct Shortcut: Hashable, Codable {
/**
Carbon modifiers are not always stored as the same number.
For example, the system has `⌃F2` stored with the modifiers number `135168`, but if you press the keyboard shortcut, you get `4096`.
*/
private static func normalizeModifiers(_ carbonModifiers: Int) -> Int {
NSEvent.ModifierFlags(carbon: carbonModifiers).carbon
}
/**
The keyboard key of the shortcut.
*/
public var key: Key? { Key(rawValue: carbonKeyCode) }
/**
The modifier keys of the shortcut.
*/
public var modifiers: NSEvent.ModifierFlags { NSEvent.ModifierFlags(carbon: carbonModifiers) }
/**
Low-level represetation of the key.
You most likely don't need this.
*/
public let carbonKeyCode: Int
/**
Low-level representation of the modifier keys.
You most likely don't need this.
*/
public let carbonModifiers: Int
/**
Initialize from a strongly-typed key and modifiers.
*/
public init(_ key: Key, modifiers: NSEvent.ModifierFlags = []) {
self.init(
carbonKeyCode: key.rawValue,
carbonModifiers: modifiers.carbon
)
}
/**
Initialize from a key event.
*/
public init?(event: NSEvent) {
guard event.isKeyEvent else {
return nil
}
self.init(
carbonKeyCode: Int(event.keyCode),
carbonModifiers: event.modifierFlags.carbon
)
}
/**
Initialize from a keyboard shortcut stored by `Recorder` or `RecorderCocoa`.
*/
public init?(name: Name) {
guard let shortcut = getShortcut(for: name) else {
return nil
}
self = shortcut
}
/**
Initialize from a key code number and modifier code.
You most likely don't need this.
*/
public init(carbonKeyCode: Int, carbonModifiers: Int = 0) {
self.carbonKeyCode = carbonKeyCode
self.carbonModifiers = Self.normalizeModifiers(carbonModifiers)
}
}
}
extension KeyboardShortcuts.Shortcut {
/**
System-defined keyboard shortcuts.
*/
static var system: [Self] {
CarbonKeyboardShortcuts.system
}
/**
Check whether the keyboard shortcut is already taken by the system.
*/
var isTakenBySystem: Bool {
guard self != Self(.f12, modifiers: []) else {
return false
}
return Self.system.contains(self)
}
}
extension KeyboardShortcuts.Shortcut {
/**
Recursively finds a menu item in the given menu that has a matching key equivalent and modifier.
*/
func menuItemWithMatchingShortcut(in menu: NSMenu) -> NSMenuItem? {
for item in menu.items {
var keyEquivalent = item.keyEquivalent
var keyEquivalentModifierMask = item.keyEquivalentModifierMask
if modifiers.contains(.shift), keyEquivalent.lowercased() != keyEquivalent {
keyEquivalent = keyEquivalent.lowercased()
keyEquivalentModifierMask.insert(.shift)
}
if
keyToCharacter() == keyEquivalent,
modifiers == keyEquivalentModifierMask
{
return item
}
if
let submenu = item.submenu,
let menuItem = menuItemWithMatchingShortcut(in: submenu)
{
return menuItem
}
}
return nil
}
/**
Returns a menu item in the app's main menu that has a matching key equivalent and modifier.
*/
var takenByMainMenu: NSMenuItem? {
guard let mainMenu = NSApp.mainMenu else {
return nil
}
return menuItemWithMatchingShortcut(in: mainMenu)
}
}
private var keyToCharacterMapping: [KeyboardShortcuts.Key: String] = [
.return: "↩",
.delete: "⌫",
.deleteForward: "⌦",
.end: "↘",
.escape: "⎋",
.help: "?⃝",
.home: "↖",
.space: "Space", // This matches what macOS uses.
.tab: "⇥",
.pageUp: "⇞",
.pageDown: "⇟",
.upArrow: "↑",
.rightArrow: "→",
.downArrow: "↓",
.leftArrow: "←",
.f1: "F1",
.f2: "F2",
.f3: "F3",
.f4: "F4",
.f5: "F5",
.f6: "F6",
.f7: "F7",
.f8: "F8",
.f9: "F9",
.f10: "F10",
.f11: "F11",
.f12: "F12",
.f13: "F13",
.f14: "F14",
.f15: "F15",
.f16: "F16",
.f17: "F17",
.f18: "F18",
.f19: "F19",
.f20: "F20",
// Representations for numeric keypad keys with ⃣ Unicode U+20e3 'COMBINING ENCLOSING KEYCAP'
.keypad0: "0\u{20e3}",
.keypad1: "1\u{20e3}",
.keypad2: "2\u{20e3}",
.keypad3: "3\u{20e3}",
.keypad4: "4\u{20e3}",
.keypad5: "5\u{20e3}",
.keypad6: "6\u{20e3}",
.keypad7: "7\u{20e3}",
.keypad8: "8\u{20e3}",
.keypad9: "9\u{20e3}",
// 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.
.keypadClear: "☒\u{20e3}", // The combined symbol appears bigger than the other combined 'keycaps'
// TODO: Respect locale decimal separator ("." or ",")
.keypadDecimal: ".\u{20e3}",
.keypadDivide: "/\u{20e3}",
// "⏎" 'Return Symbol' (U+23CE) but "↩" 'Leftwards Arrow with Hook' (U+00d7) seems to be more common on macOS.
.keypadEnter: "↩\u{20e3}", // The combined symbol appears bigger than the other combined 'keycaps'
.keypadEquals: "=\u{20e3}",
.keypadMinus: "-\u{20e3}",
.keypadMultiply: "*\u{20e3}",
.keypadPlus: "+\u{20e3}"
]
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? {
// `TISCopyCurrentASCIICapableKeyboardLayoutInputSource` works on a background thread, but crashes when used in a `NSBackgroundActivityScheduler` task, so we guard against that. It only crashes when running from Xcode, not in release builds, but it's probably safest to not call it from a `NSBackgroundActivityScheduler` no matter what.
assert(!DispatchQueue.isCurrentQueueNSBackgroundActivitySchedulerQueue, "This method cannot be used in a `NSBackgroundActivityScheduler` task")
// Some characters cannot be automatically translated.
if
let key,
let character = keyToCharacterMapping[key]
{
return character
}
guard
let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue(),
let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData)
else {
return nil
}
let layoutData = unsafeBitCast(layoutDataPointer, to: CFData.self)
let keyLayout = unsafeBitCast(CFDataGetBytePtr(layoutData), to: UnsafePointer<CoreServices.UCKeyboardLayout>.self)
var deadKeyState: UInt32 = 0
let maxLength = 4
var length = 0
var characters = [UniChar](repeating: 0, count: maxLength)
let error = CoreServices.UCKeyTranslate(
keyLayout,
UInt16(carbonKeyCode),
UInt16(CoreServices.kUCKeyActionDisplay),
0, // No modifiers
UInt32(LMGetKbdType()),
OptionBits(CoreServices.kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
maxLength,
&length,
&characters
)
guard error == noErr else {
return nil
}
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,
let string = keyToKeyEquivalentString[key]
else {
return ""
}
return string
}
return keyString
}
}
extension KeyboardShortcuts.Shortcut: CustomStringConvertible {
/**
The string representation of the keyboard shortcut.
```swift
print(KeyboardShortcuts.Shortcut(.a, modifiers: [.command]))
//=> "⌘A"
```
*/
public var description: String {
// We use `.capitalized` so it correctly handles “⌘Space”.
modifiers.description + (keyToCharacter()?.capitalized ?? "�")
}
}