import AppKit import Carbon public struct KeyboardShortcut: Equatable, Sendable { public let keyCode: UInt16 public let modifiers: NSEvent.ModifierFlags public init?(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) { let normalized = modifiers.intersection([.command, .option, .control, .shift]) guard !normalized.intersection([.command, .option, .control]).isEmpty, Self.supports(keyCode) else { return nil } self.keyCode = keyCode self.modifiers = normalized } public static let commandSpace = KeyboardShortcut(keyCode: 49, modifiers: .command)! @MainActor public var displayName: String { var result = "" if modifiers.contains(.control) { result += "⌃" } if modifiers.contains(.option) { result += "⌥" } if modifiers.contains(.shift) { result += "⇧" } if modifiers.contains(.command) { result += "⌘" } return result + keyName } var carbonModifiers: UInt32 { var result: UInt32 = 0 if modifiers.contains(.command) { result |= UInt32(cmdKey) } if modifiers.contains(.option) { result |= UInt32(optionKey) } if modifiers.contains(.control) { result |= UInt32(controlKey) } if modifiers.contains(.shift) { result |= UInt32(shiftKey) } return result } var storedValue: [String: Int] { ["keyCode": Int(keyCode), "modifiers": Int(modifiers.rawValue)] } init?(storedValue: Any?) { guard let record = storedValue as? [String: Any], let code = Self.storedInteger(record["keyCode"]), let flags = Self.storedInteger(record["modifiers"]), let keyCode = UInt16(exactly: code), flags >= 0, let shortcut = Self(keyCode: keyCode, modifiers: .init(rawValue: UInt(flags))), shortcut.modifiers.rawValue == UInt(flags) else { return nil } self = shortcut } private static func storedInteger(_ value: Any?) -> Int? { guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } return Int(exactly: number.doubleValue) } private static func supports(_ keyCode: UInt16) -> Bool { switch keyCode { case 0...51, 53, 64, 65, 67, 69, 71, 75, 76, 78...88, 90...107, 109...111, 113...126: return true default: return false } } @MainActor var keyName: String { switch keyCode { case 36: return "Return" case 48: return "Tab" case 49: return "Space" case 51: return "Delete" case 53: return "Escape" case 64: return "F17" case 71: return "Clear" case 76: return "Enter" case 79: return "F18" case 80: return "F19" case 90: return "F20" case 96: return "F5" case 97: return "F6" case 98: return "F7" case 99: return "F3" case 100: return "F8" case 101: return "F9" case 102: return "Eisu" case 103: return "F11" case 104: return "Kana" case 105: return "F13" case 106: return "F16" case 107: return "F14" case 109: return "F10" case 110: return "Menu" case 111: return "F12" case 113: return "F15" case 114: return "Help" case 115: return "Home" case 116: return "Page Up" case 117: return "Forward Delete" case 118: return "F4" case 119: return "End" case 120: return "F2" case 121: return "Page Down" case 122: return "F1" case 123: return "←" case 124: return "→" case 125: return "↓" case 126: return "↑" default: return translatedKeyName ?? "Key \(keyCode)" } } @MainActor private var translatedKeyName: String? { guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), let property = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return nil } let data = Unmanaged.fromOpaque(property).takeUnretainedValue() guard let bytes = CFDataGetBytePtr(data) else { return nil } let layout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self) var deadKeyState: UInt32 = 0 var length = 0 var characters = [UniChar](repeating: 0, count: 8) let status = UCKeyTranslate( layout, keyCode, UInt16(kUCKeyActionDisplay), 0, UInt32(LMGetKbdType()), UInt32(kUCKeyTranslateNoDeadKeysMask), &deadKeyState, characters.count, &length, &characters ) guard status == noErr, length > 0 else { return nil } return String(utf16CodeUnits: characters, count: length).uppercased() } }