summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Package.swift28
-rw-r--r--Sources/DDHotKey/DDHotKey.swift48
-rw-r--r--Sources/DDHotKey/DDHotKeyCenter.swift154
-rw-r--r--Sources/DDHotKey/DDHotKeyTranslation.swift59
-rw-r--r--Sources/DDHotKey/DDHotKeyUtilities.swift142
-rw-r--r--Tests/DDHotKeyTests/DDHotKeyTests.swift18
-rw-r--r--Tests/DDHotKeyTests/XCTestManifests.swift9
-rw-r--r--Tests/LinuxMain.swift7
9 files changed, 467 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index 9cbdbf4..c22af45 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@ build
**/*.mode1v3
**/*.perspectivev3
**/*.pbxuser
+.build
+DDHotKey.xcodeproj
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..573352b
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,28 @@
+// swift-tools-version:5.1
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "DDHotKey",
+ products: [
+ // Products define the executables and libraries produced by a package, and make them visible to other packages.
+ .library(
+ name: "DDHotKey",
+ targets: ["DDHotKey"]),
+ ],
+ dependencies: [
+ // Dependencies declare other packages that this package depends on.
+ // .package(url: /* package url */, from: "1.0.0"),
+ ],
+ targets: [
+ // Targets are the basic building blocks of a package. A target can define a module or a test suite.
+ // Targets can depend on other targets in this package, and on products in packages which this package depends on.
+ .target(
+ name: "DDHotKey",
+ dependencies: []),
+ .testTarget(
+ name: "DDHotKeyTests",
+ dependencies: ["DDHotKey"]),
+ ]
+)
diff --git a/Sources/DDHotKey/DDHotKey.swift b/Sources/DDHotKey/DDHotKey.swift
new file mode 100644
index 0000000..0f3c93e
--- /dev/null
+++ b/Sources/DDHotKey/DDHotKey.swift
@@ -0,0 +1,48 @@
+//
+// DDHotKey.swift
+// DDHotKey
+//
+// Created by Dave DeLong on 8/28/19.
+//
+
+import Cocoa
+import Carbon
+
+public class DDHotKey {
+ internal var hotKeyID: UInt32?
+ internal var hotKeyRef: EventHotKeyRef?
+
+ internal let uuid = UUID()
+ internal let keyCode: CGKeyCode
+ internal let modifiers: NSEvent.ModifierFlags
+ internal let handler: (NSEvent) -> Void
+
+ public init(keyCode: CGKeyCode, modifiers: NSEvent.ModifierFlags, handler: @escaping (NSEvent) -> Void) {
+ self.keyCode = keyCode
+ self.modifiers = modifiers
+ self.handler = handler
+ }
+
+ public convenience init?(shortcut: String, handler: @escaping (NSEvent) -> Void) {
+ guard let (keyCode, modifiers) = keyCodeAndModifiers(from: shortcut) else {
+ return nil
+ }
+
+ self.init(keyCode: keyCode, modifiers: modifiers, handler: handler)
+ }
+
+ internal func invoke(with event: NSEvent) {
+ handler(event)
+ }
+}
+
+public struct DDHotKeyOld: Hashable {
+ var hotKeyID: UInt32 = 0
+ var keyCode: UInt16 = 0
+ var text = "Hello, World!"
+
+ func invoke(with event: NSEvent) -> OSStatus {
+ return noErr
+ }
+}
+
diff --git a/Sources/DDHotKey/DDHotKeyCenter.swift b/Sources/DDHotKey/DDHotKeyCenter.swift
new file mode 100644
index 0000000..84a456d
--- /dev/null
+++ b/Sources/DDHotKey/DDHotKeyCenter.swift
@@ -0,0 +1,154 @@
+//
+// DDHotKeyCenter.swift
+// DDHotKey
+//
+// Created by Dave DeLong on 8/28/19.
+//
+
+import Cocoa
+import Carbon
+
+public class DDHotKeyCenter {
+
+ public enum RegistrationError: Error {
+ case alreadyRegistered
+ case tooManyHotKeys
+ case conflictsWithExistingHotKey(DDHotKey)
+ case unableToRegisterHotKey(OSStatus)
+ }
+
+ public enum UnregistrationError: Error {
+ case notRegistered
+ case unknownHotKey
+ case unableToUnregisterHotKey(OSStatus)
+ }
+
+ public static let shared = DDHotKeyCenter()
+
+ private var registered = Dictionary<UUID, DDHotKey>()
+ private var nextID: UInt32 = 1
+
+ private init() {
+
+ var spec = EventTypeSpec()
+ spec.eventClass = OSType(kEventClassKeyboard)
+ spec.eventKind = UInt32(kEventHotKeyReleased)
+ InstallEventHandler(GetApplicationEventTarget(),
+ handler,
+ 1,
+ &spec,
+ nil,
+ nil)
+ }
+
+ internal func hotKeysMatching(_ filter: (DDHotKey) -> Bool) -> Array<DDHotKey> {
+ return registered.values.filter(filter)
+ }
+
+ public func registeredHotKeys() -> Array<DDHotKey> {
+ return Array(registered.values)
+ }
+
+ public func register(hotKey: DDHotKey) throws /* RegistrationError */ {
+ // cannot register a hot key that is already registered
+ if hotKey.hotKeyID != nil {
+ throw RegistrationError.alreadyRegistered
+ }
+
+ guard nextID < UInt32.max else {
+ throw RegistrationError.tooManyHotKeys
+ }
+
+ // cannot register a hot key that has the same invocation as an existing hotkey
+ let matching = registered.values.filter { $0.keyCode == hotKey.keyCode && $0.modifiers == hotKey.modifiers }
+ if matching.isEmpty == false {
+ throw RegistrationError.conflictsWithExistingHotKey(matching[0])
+ }
+
+ let hotKeyID = EventHotKeyID(signature: OSType(fourCharCode: "htk1"), id: nextID)
+ let flags = carbonModifiers(from: hotKey.modifiers)
+
+ var hotKeyRef: EventHotKeyRef?
+ let error = RegisterEventHotKey(UInt32(hotKey.keyCode), flags, hotKeyID, GetEventDispatcherTarget(), 0, &hotKeyRef)
+
+ if error != noErr {
+ throw RegistrationError.unableToRegisterHotKey(error)
+ }
+
+ hotKey.hotKeyRef = hotKeyRef
+ hotKey.hotKeyID = nextID
+ registered[hotKey.uuid] = hotKey
+
+ nextID += 1
+ }
+
+ public func unregister(hotKey: DDHotKey) throws /* UnregistrationError */ {
+ guard let ref = hotKey.hotKeyRef, hotKey.hotKeyID != nil else {
+ throw UnregistrationError.notRegistered
+ }
+
+ guard let existing = registered[hotKey.uuid], existing === hotKey else {
+ throw UnregistrationError.unknownHotKey
+ }
+
+ let status = UnregisterEventHotKey(ref)
+
+ guard status == noErr else {
+ throw UnregistrationError.unableToUnregisterHotKey(status)
+ }
+
+ registered.removeValue(forKey: hotKey.uuid)
+ }
+}
+
+
+fileprivate var handler: EventHandlerProcPtr = { (callRef, eventRef, context) -> OSStatus in
+ return autoreleasepool { () -> OSStatus in
+ var hotKeyID = EventHotKeyID()
+ GetEventParameter(eventRef,
+ EventParamName(kEventParamDirectObject),
+ EventParamType(typeEventHotKeyID),
+ nil,
+ MemoryLayout<EventHotKeyID>.size,
+ nil,
+ &hotKeyID);
+
+ let keyID = hotKeyID.id
+ let matches = DDHotKeyCenter.shared.hotKeysMatching { $0.hotKeyID == keyID }
+ if matches.count != 1 {
+ print("Unable to find a single hotkey with id \(keyID)")
+ return OSStatus(errAborted)
+ }
+ let matching = matches[matches.startIndex]
+
+ guard let ref = eventRef else {
+ print("Missing EventRef to handle hotkey with id \(keyID)")
+ return OSStatus(errAborted)
+ }
+
+ guard let event = NSEvent(eventRef: UnsafeRawPointer(ref)) else {
+ print("Unable to create NSEvent from EventRef \(ref) for the hotkey with id \(keyID)")
+ return OSStatus(errAborted)
+ }
+
+ let keyEvent = NSEvent.keyEvent(with: .keyUp,
+ location: event.locationInWindow,
+ modifierFlags: event.modifierFlags,
+ timestamp: event.timestamp,
+ windowNumber: -1,
+ context: nil,
+ characters: "",
+ charactersIgnoringModifiers: "",
+ isARepeat: false,
+ keyCode: matching.keyCode)
+
+ guard let key = keyEvent else {
+ print("Unable to create key event from NSEvent \(event) for the hotkey with id \(keyID)")
+ return OSStatus(errAborted)
+ }
+
+ matching.invoke(with: key)
+ return noErr
+ }
+
+}
diff --git a/Sources/DDHotKey/DDHotKeyTranslation.swift b/Sources/DDHotKey/DDHotKeyTranslation.swift
new file mode 100644
index 0000000..ce66d0d
--- /dev/null
+++ b/Sources/DDHotKey/DDHotKeyTranslation.swift
@@ -0,0 +1,59 @@
+//
+// DDHotKeyTranslation.swift
+// DDHotKey
+//
+// Created by Dave DeLong on 8/28/19.
+//
+
+import Cocoa
+import Carbon
+
+fileprivate let standardModifiers: Dictionary<Int, NSEvent.ModifierFlags> = [
+ kVK_Option: .option,
+ kVK_Shift: .shift,
+ kVK_Command: .command,
+ kVK_Control: .control
+]
+
+internal func keyCodeAndModifiers(from string: String) -> (CGKeyCode, NSEvent.ModifierFlags)? {
+ var flags = NSEvent.ModifierFlags()
+ var keyCode: Int?
+
+ for character in string {
+ guard let code = keycode(for: String(character)) else { return nil }
+ if let modifier = standardModifiers[code] {
+ flags.insert(modifier)
+ } else if keyCode == nil {
+ keyCode = code
+ } else {
+ return nil
+ }
+ }
+
+ guard let code = keyCode else { return nil }
+
+ return (CGKeyCode(code), flags)
+}
+
+internal func stringFrom(keyCode: CGKeyCode, modifiers: NSEvent.ModifierFlags) -> String {
+ var final = ""
+ if modifiers.contains(.control) {
+ final += string(for: kVK_Control)!
+ }
+ if modifiers.contains(.option) {
+ final += string(for: kVK_Option)!
+ }
+ if modifiers.contains(.shift) {
+ final += string(for: kVK_Shift)!
+ }
+ if modifiers.contains(.command) {
+ final += string(for: kVK_Command)!
+ }
+
+ if standardModifiers[Int(keyCode)] != nil { return final }
+
+ if let mapped = string(for: Int(keyCode), carbonModifiers: carbonModifiers(from: modifiers)) {
+ final += mapped
+ }
+ return final
+}
diff --git a/Sources/DDHotKey/DDHotKeyUtilities.swift b/Sources/DDHotKey/DDHotKeyUtilities.swift
new file mode 100644
index 0000000..cc2fa04
--- /dev/null
+++ b/Sources/DDHotKey/DDHotKeyUtilities.swift
@@ -0,0 +1,142 @@
+//
+// DDHotKeyUtilities.swift
+// DDHotKey
+//
+// Created by Dave DeLong on 8/28/19.
+//
+
+import Carbon
+import Cocoa
+
+internal extension OSType {
+
+ init(fourCharCode: String) {
+
+ let scalars = Array(fourCharCode.unicodeScalars)[0 ..< 4]
+ var sum: UInt32 = 0
+ for scalar in scalars {
+ sum = (sum << 8) + scalar.value
+ }
+ self = sum
+ }
+
+}
+
+fileprivate let characterMap: Dictionary<Int, String> = [
+ kVK_Return: "↩",
+ kVK_Tab: "⇥",
+ kVK_Space: "⎵",
+ kVK_Delete: "⌫",
+ kVK_Escape: "⎋",
+ kVK_Command: "⌘",
+ kVK_Shift: "⇧",
+ kVK_CapsLock: "⇪",
+ kVK_Option: "⌥",
+ kVK_Control: "⌃",
+ kVK_RightShift: "⇧",
+ kVK_RightOption: "⌥",
+ kVK_RightControl: "⌃",
+ kVK_VolumeUp: "🔊",
+ kVK_VolumeDown: "🔈",
+ kVK_Mute: "🔇",
+ kVK_Function: "\u{2318}",
+ kVK_F1: "F1",
+ kVK_F2: "F2",
+ kVK_F3: "F3",
+ kVK_F4: "F4",
+ kVK_F5: "F5",
+ kVK_F6: "F6",
+ kVK_F7: "F7",
+ kVK_F8: "F8",
+ kVK_F9: "F9",
+ kVK_F10: "F10",
+ kVK_F11: "F11",
+ kVK_F12: "F12",
+ kVK_F13: "F13",
+ kVK_F14: "F14",
+ kVK_F15: "F15",
+ kVK_F16: "F16",
+ kVK_F17: "F17",
+ kVK_F18: "F18",
+ kVK_F19: "F19",
+ kVK_F20: "F20",
+ // kVK_Help: "",
+ kVK_ForwardDelete: "⌦",
+ kVK_Home: "↖",
+ kVK_End: "↘",
+ kVK_PageUp: "⇞",
+ kVK_PageDown: "⇟",
+ kVK_LeftArrow: "←",
+ kVK_RightArrow: "→",
+ kVK_DownArrow: "↓",
+ kVK_UpArrow: "↑",
+]
+
+internal func carbonModifiers(from cocoaModifiers: NSEvent.ModifierFlags) -> UInt32 {
+ var newFlags: Int = 0
+ if cocoaModifiers.contains(.control) { newFlags |= controlKey }
+ if cocoaModifiers.contains(.command) { newFlags |= cmdKey }
+ if cocoaModifiers.contains(.shift) { newFlags |= shiftKey }
+ if cocoaModifiers.contains(.option) { newFlags |= optionKey }
+ if cocoaModifiers.contains(.capsLock) { newFlags |= alphaLock }
+ return UInt32(newFlags)
+}
+
+fileprivate let keycodesToCharacters: Dictionary<Int, String> = {
+ var map = characterMap
+ for code in 0 ..< 65536 {
+ if map[code] != nil { continue }
+ if let character = string(for: code, useCache: false) {
+ map[code] = character
+ }
+ }
+ return map
+}()
+
+fileprivate let charactersToKeycodes: Dictionary<String, Int> = {
+ var map = Dictionary<String, Int>(minimumCapacity: keycodesToCharacters.count)
+ for (code, string) in keycodesToCharacters {
+ map[string] = code
+ }
+ return map
+}()
+
+fileprivate let layoutData: CFData? = {
+ var currentKeyboard = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
+ if currentKeyboard == nil {
+ currentKeyboard = TISCopyCurrentASCIICapableKeyboardInputSource()?.takeRetainedValue()
+ }
+ guard let keyboard = currentKeyboard else { return nil }
+
+ guard let ptr = TISGetInputSourceProperty(keyboard, kTISPropertyUnicodeKeyLayoutData) else { return nil }
+ return Unmanaged<CFData>.fromOpaque(ptr).takeUnretainedValue()
+}()
+
+internal func string(for keyCode: Int, carbonModifiers: UInt32 = UInt32(alphaLock >> 8), useCache: Bool = true) -> String? {
+ if useCache == true {
+ if let mapped = keycodesToCharacters[keyCode] { return mapped }
+ }
+
+ guard let data = layoutData else { return nil }
+ guard let bytePtr = CFDataGetBytePtr(data) else { return nil }
+
+ var deadKeyState: UInt32 = 0
+ var actualStringLength: Int = 0
+ var characters = Array<UniChar>(repeating: 0, count: 255)
+
+ let status = bytePtr.withMemoryRebound(to: UCKeyboardLayout.self, capacity: 1) { pointer -> OSStatus in
+ return UCKeyTranslate(pointer,
+ UInt16(keyCode), UInt16(kUCKeyActionDown), carbonModifiers,
+ UInt32(LMGetKbdType()), 0,
+ &deadKeyState, 255,
+ &actualStringLength, &characters)
+ }
+
+ guard status == noErr else { return nil }
+ guard actualStringLength > 0 else { return nil }
+ return String(utf16CodeUnits: &characters, count: actualStringLength)
+}
+
+internal func keycode(for string: String) -> Int? {
+ return charactersToKeycodes[string]
+}
diff --git a/Tests/DDHotKeyTests/DDHotKeyTests.swift b/Tests/DDHotKeyTests/DDHotKeyTests.swift
new file mode 100644
index 0000000..5e523af
--- /dev/null
+++ b/Tests/DDHotKeyTests/DDHotKeyTests.swift
@@ -0,0 +1,18 @@
+import XCTest
+import Carbon
+@testable import DDHotKey
+
+final class DDHotKeyTests: XCTestCase {
+
+ func testExample() {
+ // This is an example of a functional test case.
+ // Use XCTAssert and related functions to verify your tests produce the correct
+ // results.
+ let s = string(for: kVK_ANSI_A)
+ print("\(s)")
+ }
+
+ static var allTests = [
+ ("testExample", testExample),
+ ]
+}
diff --git a/Tests/DDHotKeyTests/XCTestManifests.swift b/Tests/DDHotKeyTests/XCTestManifests.swift
new file mode 100644
index 0000000..70843ac
--- /dev/null
+++ b/Tests/DDHotKeyTests/XCTestManifests.swift
@@ -0,0 +1,9 @@
+import XCTest
+
+#if !canImport(ObjectiveC)
+public func allTests() -> [XCTestCaseEntry] {
+ return [
+ testCase(DDHotKeyTests.allTests),
+ ]
+}
+#endif
diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift
new file mode 100644
index 0000000..ebc7760
--- /dev/null
+++ b/Tests/LinuxMain.swift
@@ -0,0 +1,7 @@
+import XCTest
+
+import DDHotKeyTests
+
+var tests = [XCTestCaseEntry]()
+tests += DDHotKeyTests.allTests()
+XCTMain(tests)