aboutsummaryrefslogtreecommitdiff
path: root/menubar/hotkey.swift
diff options
context:
space:
mode:
Diffstat (limited to 'menubar/hotkey.swift')
-rw-r--r--menubar/hotkey.swift146
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
+ }
+}