144 lines
5.1 KiB
Swift
144 lines
5.1 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 "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
|
|
}
|
|
}
|