refactor: centralize shortcut recording lifetime

This commit is contained in:
Valdemar Lorenzen
2026-10-16 10:23:20 +02:00
parent 04e505b846
commit 6725be8ad5
7 changed files with 199 additions and 30 deletions
+8 -18
View File
@@ -249,6 +249,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private func refresh() {
refreshLogin()
recorder.isRecording = shortcuts.isRecording
commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0
recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut")
let name = shortcut.shortcut?.displayName
@@ -266,10 +267,9 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
}
@objc private func beginRecording() {
guard !recorder.isRecording else { return }
guard !shortcuts.isRecording else { return }
errorLabel.stringValue = ""
shortcuts.suspend()
recorder.isRecording = true
shortcuts.beginRecording()
refresh()
if window?.makeFirstResponder(recorder) != true {
cancelRecording()
@@ -283,9 +283,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
return
}
guard let candidate = KeyboardShortcut(keyCode: event.keyCode, modifiers: event.modifierFlags) else {
recorder.isRecording = false
errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut."
restoreSavedShortcuts()
appendShortcutErrors(shortcuts.cancelRecording())
refresh()
return
}
@@ -293,28 +292,19 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
}
private func apply(_ candidate: KeyboardShortcut?) {
let wasRecording = recorder.isRecording
recorder.isRecording = false
errorLabel.stringValue = ""
do {
try shortcuts.setShortcut(candidate, at: destinationSelector.indexOfSelectedItem)
} catch {
errorLabel.stringValue = error.localizedDescription
}
if wasRecording { restoreSavedShortcuts() }
appendShortcutErrors(shortcuts.finishRecording(candidate, at: destinationSelector.indexOfSelectedItem))
refresh()
}
private func cancelRecording() {
guard recorder.isRecording else { return }
recorder.isRecording = false
guard shortcuts.isRecording else { return }
errorLabel.stringValue = ""
restoreSavedShortcuts()
appendShortcutErrors(shortcuts.cancelRecording())
refresh()
}
private func restoreSavedShortcuts() {
let errors = shortcuts.start()
private func appendShortcutErrors(_ errors: [String]) {
if !errors.isEmpty {
let message = errors.joined(separator: "\n")
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
+25 -5
View File
@@ -10,6 +10,18 @@ public final class GlobalShortcut {
private let defaults: UserDefaults
private let action: @MainActor () -> Void
private let native = NativeHotKey()
private var registration: Registration?
struct Registration {
var activate: (KeyboardShortcut) throws -> Void
var unregister: () throws -> Void
var stop: () -> Void
}
convenience init(defaults: UserDefaults, storageKey: String, registration: Registration) {
self.init(defaults: defaults, storageKey: storageKey, action: {})
self.registration = registration
}
public init(
defaults: UserDefaults = .standard,
@@ -44,7 +56,7 @@ public final class GlobalShortcut {
try activate(candidate)
defaults.set(candidate.storedValue, forKey: storageKey)
} else {
try native.unregister()
try unregister()
isRegistered = false
defaults.removeObject(forKey: storageKey)
}
@@ -53,7 +65,7 @@ public final class GlobalShortcut {
public func suspend() {
do {
try native.unregister()
try unregister()
isRegistered = false
} catch {
NSLog("Conjure could not suspend its shortcut: %@", error.localizedDescription)
@@ -61,7 +73,7 @@ public final class GlobalShortcut {
}
public func stop() {
native.dispose()
if let registration { registration.stop() } else { native.dispose() }
isRegistered = false
}
@@ -69,9 +81,17 @@ public final class GlobalShortcut {
action()
}
private func unregister() throws {
if let registration { try registration.unregister() } else { try native.unregister() }
}
private func activate(_ candidate: KeyboardShortcut) throws {
try Self.rejectSystemConflict(candidate)
try native.register(candidate)
if let registration {
try registration.activate(candidate)
} else {
try Self.rejectSystemConflict(candidate)
try native.register(candidate)
}
isRegistered = true
}
+20 -1
View File
@@ -27,6 +27,13 @@ public final class GlobalShortcuts {
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,
@@ -60,11 +67,23 @@ public final class GlobalShortcuts {
return errors
}
public func suspend() {
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() }