diff options
Diffstat (limited to 'menubar/hotkey.swift')
| -rw-r--r-- | menubar/hotkey.swift | 146 |
1 files changed, 146 insertions, 0 deletions
diff --git a/menubar/hotkey.swift b/menubar/hotkey.swift new file mode 100644 index 0000000..e6a5aa7 --- /dev/null +++ b/menubar/hotkey.swift @@ -0,0 +1,146 @@ +// The global shortcut and the settings file it comes from. +// +// The shortcut uses Carbon's RegisterEventHotKey: no Accessibility or Input +// Monitoring permission, the key is consumed, and a clash with another app +// is reported instead of failing silently. + +import AppKit +import Carbon.HIToolbox + +// MARK: settings file + +struct Settings: Equatable { + var hotkey = "ctrl+opt+cmd+e" + + static var directory: String { + environment["DINGGER_CONFIG_DIR"] ?? (NSHomeDirectory() + "/.config/dingger") + } + static var path: String { directory + "/config.toml" } + + static let template = """ + # dingger settings. The menu bar app reloads this file when you save it. + + [panel] + # Global shortcut that shows or hides the floating panel. + # Modifiers: ctrl, opt, cmd, shift. Keys: a-z, 0-9, f1-f12, space. + hotkey = "ctrl+opt+cmd+e" + + """ + + /// Create the file with defaults and comments if it does not exist yet. + static func ensureFile() { + guard !FileManager.default.fileExists(atPath: path) else { return } + try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + try? template.write(toFile: path, atomically: true, encoding: .utf8) + } + + /// Read `key = "value"` lines; unknown keys are ignored. + static func load() -> Settings { + var settings = Settings() + guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return settings } + for line in text.split(separator: "\n") { + let parts = line.split(separator: "=", maxSplits: 1).map { + $0.trimmingCharacters(in: .whitespaces) + } + guard parts.count == 2, !parts[0].hasPrefix("#") else { continue } + let value = parts[1].trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + if parts[0] == "hotkey" { settings.hotkey = value } + } + return settings + } + + static func modified() -> Date? { + (try? FileManager.default.attributesOfItem(atPath: path))?[.modificationDate] as? Date + } +} + +// MARK: shortcut parsing + +struct Hotkey: Equatable { + let keyCode: UInt32 + let carbonModifiers: UInt32 + let modifierFlags: NSEvent.ModifierFlags + let key: String + + /// How macOS writes the shortcut, e.g. "⌃⌥⌘E". + var display: String { + var text = "" + if modifierFlags.contains(.control) { text += "⌃" } + if modifierFlags.contains(.option) { text += "⌥" } + if modifierFlags.contains(.shift) { text += "⇧" } + if modifierFlags.contains(.command) { text += "⌘" } + return text + key.uppercased() + } + + private static let keyCodes: [String: Int] = { + var codes: [String: Int] = [ + "a": kVK_ANSI_A, "b": kVK_ANSI_B, "c": kVK_ANSI_C, "d": kVK_ANSI_D, "e": kVK_ANSI_E, + "f": kVK_ANSI_F, "g": kVK_ANSI_G, "h": kVK_ANSI_H, "i": kVK_ANSI_I, "j": kVK_ANSI_J, + "k": kVK_ANSI_K, "l": kVK_ANSI_L, "m": kVK_ANSI_M, "n": kVK_ANSI_N, "o": kVK_ANSI_O, + "p": kVK_ANSI_P, "q": kVK_ANSI_Q, "r": kVK_ANSI_R, "s": kVK_ANSI_S, "t": kVK_ANSI_T, + "u": kVK_ANSI_U, "v": kVK_ANSI_V, "w": kVK_ANSI_W, "x": kVK_ANSI_X, "y": kVK_ANSI_Y, + "z": kVK_ANSI_Z, + "0": kVK_ANSI_0, "1": kVK_ANSI_1, "2": kVK_ANSI_2, "3": kVK_ANSI_3, "4": kVK_ANSI_4, + "5": kVK_ANSI_5, "6": kVK_ANSI_6, "7": kVK_ANSI_7, "8": kVK_ANSI_8, "9": kVK_ANSI_9, + "space": kVK_Space, + ] + let functionKeys = [kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, + kVK_F7, kVK_F8, kVK_F9, kVK_F10, kVK_F11, kVK_F12] + for (index, code) in functionKeys.enumerated() { codes["f\(index + 1)"] = code } + return codes + }() + + /// Parse "ctrl+opt+cmd+e". Requires at least one modifier and one key. + static func parse(_ spec: String) -> Hotkey? { + var carbon: UInt32 = 0 + var flags: NSEvent.ModifierFlags = [] + var key: String? + for part in spec.lowercased().split(separator: "+").map({ $0.trimmingCharacters(in: .whitespaces) }) { + switch part { + case "ctrl", "control": carbon |= UInt32(controlKey); flags.insert(.control) + case "opt", "option", "alt": carbon |= UInt32(optionKey); flags.insert(.option) + case "cmd", "command": carbon |= UInt32(cmdKey); flags.insert(.command) + case "shift": carbon |= UInt32(shiftKey); flags.insert(.shift) + default: + guard key == nil, keyCodes[part] != nil else { return nil } + key = part + } + } + guard let key, let code = keyCodes[key], carbon != 0 else { return nil } + return Hotkey(keyCode: UInt32(code), carbonModifiers: carbon, modifierFlags: flags, key: key) + } +} + +// MARK: registration + +/// One process-wide global shortcut. +enum GlobalHotkey { + private static var reference: EventHotKeyRef? + private static var action: (() -> Void)? + private static var handlerInstalled = false + + /// Register `hotkey`, replacing any previous one. Returns false if another + /// app already owns the combination. + static func register(_ hotkey: Hotkey, action newAction: @escaping () -> Void) -> Bool { + unregister() + action = newAction + if !handlerInstalled { + var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed)) + InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in + DispatchQueue.main.async { GlobalHotkey.action?() } + return noErr + }, 1, &spec, nil, nil) + handlerInstalled = true + } + let id = EventHotKeyID(signature: OSType(0x4447_4752), id: 1) // "DGGR" + let status = RegisterEventHotKey(hotkey.keyCode, hotkey.carbonModifiers, id, + GetApplicationEventTarget(), 0, &reference) + return status == noErr + } + + static func unregister() { + if let reference { UnregisterEventHotKey(reference) } + reference = nil + } +} |
