Files

145 lines
5.2 KiB
Swift

import AppKit
@MainActor
public final class GlobalShortcuts {
public enum Destination: Hashable, Sendable {
case launcher
case command(Application.Command)
public var title: String {
switch self {
case .launcher: "Applications"
case .command(let command): Application.commands.first { $0.command == command }!.name
}
}
var storageKey: String {
switch self {
case .launcher: "globalActivationShortcut"
case .command(let command): "globalCommandShortcut.\(command.rawValue)"
}
}
}
public private(set) static var destinations: [Destination] =
[.launcher] + Application.Command.allCases.map { .command($0) }
public let bindings: [GlobalShortcut]
private let defaults: UserDefaults
private var isSuspended = false
public var isRecording: Bool { isSuspended }
init(defaults: UserDefaults, bindings: [GlobalShortcut]) {
precondition(bindings.count == Self.destinations.count)
self.defaults = defaults
self.bindings = bindings
}
public init(
defaults: UserDefaults = .standard,
action: @escaping @MainActor (Destination) -> Void
) {
self.defaults = defaults
bindings = Self.destinations.map { destination in
GlobalShortcut(defaults: defaults, storageKey: destination.storageKey) {
action(destination)
}
}
}
public func start() -> [String] {
isSuspended = false
var errors: [String] = []
for (index, binding) in bindings.enumerated() {
do {
if let saved = defaults.object(forKey: Self.destinations[index].storageKey) {
guard let candidate = KeyboardShortcut(storedValue: saved) else {
throw ShortcutError.invalidSavedShortcut
}
try validate(candidate, at: index)
}
try binding.start()
} catch {
binding.suspend()
errors.append("\(Self.destinations[index].title): \(error.localizedDescription)")
}
}
return errors
}
public func beginRecording() {
isSuspended = true
bindings.forEach { $0.suspend() }
}
public func cancelRecording() -> [String] {
guard isRecording else { return [] }
return start()
}
public func finishRecording(_ candidate: KeyboardShortcut?, at index: Int) -> [String] {
var errors: [String] = []
do { try setShortcut(candidate, at: index) }
catch { errors.append(error.localizedDescription) }
return errors + cancelRecording()
}
public func stop() {
isSuspended = false
bindings.forEach { $0.stop() }
}
public func setShortcut(_ candidate: KeyboardShortcut?, at index: Int) throws {
if let candidate { try validate(candidate, at: index) }
let binding = bindings[index]
try binding.setShortcut(candidate)
if isSuspended { binding.suspend() }
}
func validate(_ candidate: KeyboardShortcut, at index: Int) throws {
if let action = Self.localAction(for: candidate, keyName: candidate.keyName) {
throw ShortcutError.inAppConflict(shortcut: candidate.displayName, action: action)
}
for (otherIndex, destination) in Self.destinations.enumerated() where otherIndex != index {
if KeyboardShortcut(storedValue: defaults.object(forKey: destination.storageKey)) == candidate {
throw ShortcutError.duplicate(shortcut: candidate.displayName, destination: destination.title)
}
}
}
// Match Conjure's menu equivalents, action buttons, and launcher/emoji panel handling.
static func localAction(for candidate: KeyboardShortcut, keyName: String) -> String? {
let key = keyName.lowercased()
if candidate.modifiers == .command {
switch key {
case ",": return "Settings"
case "h": return "Hide Conjure"
case "q": return "Quit Conjure"
case "w": return "Close Window"
case "x": return "Cut"
case "c": return "Copy"
case "v": return "Paste"
case "a": return "Select All"
case "n": return "New Note"
case "z": return "Undo"
case "m": return "Minimize"
case "k": return "entry actions"
case "p": return "translation provider selection"
case "return", "enter": return "Open, Copy, Translate, or confirm uninstall"
case "1"..."9" where key.utf8.count == 1: return "numbered application launching"
default: break
}
} else if candidate.modifiers == [.command, .option], key == "h" {
return "Hide Others"
} else if candidate.modifiers == [.command, .shift], key == "z" {
return "Redo"
} else if candidate.modifiers == .control {
switch key {
case "h", "j", "k", "l": return "launcher and emoji navigation"
default: break
}
}
return nil
}
}