116 lines
4.8 KiB
Swift
116 lines
4.8 KiB
Swift
import AppKit
|
|
import Carbon.HIToolbox
|
|
|
|
/// Serializable representation of a keyboard shortcut (modifier flags + key code).
|
|
/// Stored in UserDefaults as a JSON string.
|
|
struct HotkeyBinding: Codable, Equatable, Hashable {
|
|
var modifiers: UInt // NSEvent.ModifierFlags.rawValue, masked to cmd/shift/ctrl/opt
|
|
var keyCode: UInt16
|
|
|
|
/// Checks whether the given NSEvent matches this binding.
|
|
func matches(_ event: NSEvent) -> Bool {
|
|
let mask = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
let relevantFlags: NSEvent.ModifierFlags = [.command, .shift, .control, .option]
|
|
return mask.intersection(relevantFlags).rawValue == modifiers
|
|
&& event.keyCode == keyCode
|
|
}
|
|
|
|
/// Human-readable label like "⌘⏎" or "⌘⇧T".
|
|
var displayString: String {
|
|
var parts: [String] = []
|
|
let flags = NSEvent.ModifierFlags(rawValue: modifiers)
|
|
if flags.contains(.control) { parts.append("⌃") }
|
|
if flags.contains(.option) { parts.append("⌥") }
|
|
if flags.contains(.shift) { parts.append("⇧") }
|
|
if flags.contains(.command) { parts.append("⌘") }
|
|
parts.append(keyName)
|
|
return parts.joined()
|
|
}
|
|
|
|
private var keyName: String {
|
|
switch keyCode {
|
|
case 36: return "⏎"
|
|
case 48: return "⇥"
|
|
case 49: return "Space"
|
|
case 51: return "⌫"
|
|
case 53: return "⎋"
|
|
case 123: return "←"
|
|
case 124: return "→"
|
|
case 125: return "↓"
|
|
case 126: return "↑"
|
|
default:
|
|
// Try to get the character from the key code
|
|
let src = TISCopyCurrentKeyboardInputSource().takeRetainedValue()
|
|
let layoutDataRef = TISGetInputSourceProperty(src, kTISPropertyUnicodeKeyLayoutData)
|
|
if let layoutDataRef = layoutDataRef {
|
|
let layoutData = unsafeBitCast(layoutDataRef, to: CFData.self) as Data
|
|
var deadKeyState: UInt32 = 0
|
|
var length = 0
|
|
var chars = [UniChar](repeating: 0, count: 4)
|
|
layoutData.withUnsafeBytes { ptr in
|
|
let layoutPtr = ptr.baseAddress!.assumingMemoryBound(to: UCKeyboardLayout.self)
|
|
UCKeyTranslate(
|
|
layoutPtr,
|
|
keyCode,
|
|
UInt16(kUCKeyActionDisplay),
|
|
0, // no modifiers for the base character
|
|
UInt32(LMGetKbdType()),
|
|
UInt32(kUCKeyTranslateNoDeadKeysBit),
|
|
&deadKeyState,
|
|
4,
|
|
&length,
|
|
&chars
|
|
)
|
|
}
|
|
if length > 0 {
|
|
return String(utf16CodeUnits: chars, count: length).uppercased()
|
|
}
|
|
}
|
|
return "Key\(keyCode)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Serialization
|
|
|
|
func toJSON() -> String {
|
|
(try? String(data: JSONEncoder().encode(self), encoding: .utf8)) ?? "{}"
|
|
}
|
|
|
|
static func fromJSON(_ string: String) -> HotkeyBinding? {
|
|
guard let data = string.data(using: .utf8) else { return nil }
|
|
return try? JSONDecoder().decode(HotkeyBinding.self, from: data)
|
|
}
|
|
|
|
// MARK: - Presets
|
|
|
|
static let cmdReturn = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 36)
|
|
static let cmdT = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 17)
|
|
static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13)
|
|
static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ]
|
|
static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [
|
|
static let cmdShiftDown = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 125)
|
|
static let cmdShiftUp = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 126)
|
|
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
|
|
|
|
static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? {
|
|
guard let keyCode = keyCode(forDigit: digit) else { return nil }
|
|
return HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: keyCode)
|
|
}
|
|
|
|
static func keyCode(forDigit digit: Int) -> UInt16? {
|
|
switch digit {
|
|
case 0: return 29
|
|
case 1: return 18
|
|
case 2: return 19
|
|
case 3: return 20
|
|
case 4: return 21
|
|
case 5: return 23
|
|
case 6: return 22
|
|
case 7: return 26
|
|
case 8: return 28
|
|
case 9: return 25
|
|
default: return nil
|
|
}
|
|
}
|
|
}
|