feat: add destination-specific global shortcuts

This commit is contained in:
Valdemar Lorenzen
2026-10-15 15:00:51 +02:00
parent 8946b19b7a
commit 6bb2b4a482
12 changed files with 383 additions and 53 deletions
@@ -0,0 +1,121 @@
import AppKit
@testable import ConjureKit
import Testing
@Test @MainActor
func globalShortcutKeepsLegacyStorageAndScopesCustomStorage() throws {
let suite = "GlobalShortcutsTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let commandShiftSpace = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.command, .shift]))
defaults.set(KeyboardShortcut.commandSpace.storedValue, forKey: "globalActivationShortcut")
defaults.set(commandShiftSpace.storedValue, forKey: "customShortcut")
let legacy = GlobalShortcut(defaults: defaults) {}
let custom = GlobalShortcut(defaults: defaults, storageKey: "customShortcut") {}
#expect(legacy.shortcut == .commandSpace)
#expect(custom.shortcut == commandShiftSpace)
try custom.setShortcut(nil)
#expect(defaults.object(forKey: "customShortcut") == nil)
#expect(KeyboardShortcut(storedValue: defaults.object(forKey: "globalActivationShortcut")) == .commandSpace)
#expect(GlobalShortcut(defaults: defaults, action: {}).shortcut == .commandSpace)
}
@Test @MainActor
func duplicateGlobalBindingIsRejectedWhileSuspendedWithoutChangingSavedBinding() throws {
let suite = "GlobalShortcutsTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let candidate = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.command, .shift]))
let previous = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.control, .option]))
let emojiIndex = try #require(GlobalShortcuts.destinations.firstIndex(of: .command(.emoji)))
defaults.set(candidate.storedValue, forKey: "globalActivationShortcut")
defaults.set(previous.storedValue, forKey: "globalCommandShortcut.emoji")
let shortcuts = GlobalShortcuts(defaults: defaults) { _ in }
shortcuts.suspend()
do {
try shortcuts.setShortcut(candidate, at: emojiIndex)
Issue.record("A duplicate shortcut was accepted")
} catch {
#expect(error.localizedDescription.contains("Applications"))
#expect(error.localizedDescription.contains("already assigned"))
}
let commandReturn = try #require(KeyboardShortcut(keyCode: 36, modifiers: .command))
do {
try shortcuts.setShortcut(commandReturn, at: emojiIndex)
Issue.record("A Conjure action shortcut was accepted")
} catch {
#expect(error.localizedDescription.contains("already used by Conjure"))
#expect(error.localizedDescription.contains("confirm uninstall"))
}
#expect(shortcuts.bindings[emojiIndex].shortcut == previous)
#expect(KeyboardShortcut(storedValue: defaults.object(forKey: "globalCommandShortcut.emoji")) == previous)
#expect(shortcuts.bindings.allSatisfy { !$0.isRegistered })
try shortcuts.validate(candidate, at: 0)
try shortcuts.setShortcut(nil, at: emojiIndex)
let restored = GlobalShortcuts(defaults: defaults) { _ in }
#expect(restored.bindings[0].shortcut == candidate)
#expect(restored.bindings[emojiIndex].shortcut == nil)
}
@Test @MainActor
func localShortcutConflictsMatchMenusActionsAndPanelNavigation() throws {
let commandKey = try #require(KeyboardShortcut(keyCode: 0, modifiers: .command))
for key in [",", "h", "q", "w", "x", "c", "v", "a", "m", "k", "Return", "1", "9"] {
#expect(GlobalShortcuts.localAction(for: commandKey, keyName: key) != nil)
}
#expect(GlobalShortcuts.localAction(for: commandKey, keyName: "0") == nil)
let hideOthers = try #require(KeyboardShortcut(keyCode: 0, modifiers: [.command, .option]))
#expect(GlobalShortcuts.localAction(for: hideOthers, keyName: "H") == "Hide Others")
let controlKey = try #require(KeyboardShortcut(keyCode: 0, modifiers: .control))
for key in ["h", "j", "k", "l"] {
#expect(GlobalShortcuts.localAction(for: controlKey, keyName: key) != nil)
}
let commandShiftSpace = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.command, .shift]))
#expect(GlobalShortcuts.localAction(for: commandShiftSpace, keyName: "Space") == nil)
let commandShiftK = try #require(KeyboardShortcut(keyCode: 40, modifiers: [.command, .shift]))
#expect(GlobalShortcuts.localAction(for: commandShiftK, keyName: "K") == nil)
}
@Test @MainActor
func startupReportsEveryInvalidSavedBindingWithoutDiscardingThem() throws {
let suite = "GlobalShortcutsTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let commandReturn = try #require(KeyboardShortcut(keyCode: 36, modifiers: .command))
defaults.set("invalid", forKey: "globalActivationShortcut")
defaults.set(commandReturn.storedValue, forKey: "globalCommandShortcut.translate")
defaults.set(["keyCode": -1, "modifiers": 0], forKey: "globalCommandShortcut.emoji")
let shortcuts = GlobalShortcuts(defaults: defaults) { _ in }
let errors = shortcuts.start()
#expect(errors.count == 3)
#expect(errors[0].hasPrefix("Applications:"))
#expect(errors[1].hasPrefix("Conjure Translate:"))
#expect(errors[1].contains("already used by Conjure"))
#expect(errors[2].hasPrefix("Conjure Emoji:"))
#expect(defaults.string(forKey: "globalActivationShortcut") == "invalid")
#expect(KeyboardShortcut(storedValue: defaults.object(forKey: "globalCommandShortcut.translate")) == commandReturn)
#expect(defaults.object(forKey: "globalCommandShortcut.emoji") != nil)
#expect(shortcuts.bindings.allSatisfy { !$0.isRegistered })
}
@Test @MainActor
func startupRejectsEveryDuplicateEvenBeforeNativeRegistration() throws {
let suite = "GlobalShortcutsTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let candidate = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.command, .shift]))
defaults.set(candidate.storedValue, forKey: "globalActivationShortcut")
defaults.set(candidate.storedValue, forKey: "globalCommandShortcut.emoji")
let shortcuts = GlobalShortcuts(defaults: defaults) { _ in }
let errors = shortcuts.start()
#expect(errors.count == 2)
#expect(errors[0].contains("Conjure Emoji"))
#expect(errors[1].contains("Applications"))
#expect(shortcuts.bindings.allSatisfy { !$0.isRegistered })
}
@@ -0,0 +1,34 @@
import AppKit
import ConjureKit
import Testing
@testable import Conjure
@Test @MainActor func activationAlwaysTargetsRequestedViewAndClearsPreviousTool() throws {
_ = NSApplication.shared
let suite = "ConjureDestination.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let launcher = LauncherWindowController(history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }))
let window = try #require(launcher.window)
defer { window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let content = try #require(window.contentView)
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
launcher.present(command: .emoji)
#expect(descendants(content).contains { $0 is NSCollectionView })
search.stringValue = "happy"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
window.orderOut(nil)
launcher.toggle()
#expect(!descendants(content).contains { $0 is NSCollectionView })
#expect(search.stringValue.isEmpty)
#expect(search.accessibilityLabel() == "Search applications, calculate, or convert units and currencies")
launcher.toggle(command: .translate)
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
launcher.toggle(command: .emoji)
#expect(search.accessibilityLabel() == "Search emoji")
#expect(search.stringValue.isEmpty)
launcher.present()
#expect(!descendants(content).contains { $0 is NSCollectionView })
#expect(search.accessibilityLabel() == "Search applications, calculate, or convert units and currencies")
}
@@ -65,11 +65,11 @@ func shortcutModifierGuidanceDoesNotAppearAsAnError() throws {
let suite = "Conjure.ToolFeedbackTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let controller = SettingsWindowController(shortcut: GlobalShortcut(defaults: defaults, action: {}))
let controller = SettingsWindowController(shortcuts: GlobalShortcuts(defaults: defaults, action: { _ in }))
let window = try #require(controller.window)
let views = descendants(try #require(window.contentView))
let recorder = try #require(views.compactMap { $0 as? NSButton }.first {
$0.accessibilityLabel() == "Record global shortcut"
$0.action == NSSelectorFromString("beginRecording")
})
let error = try #require(views.compactMap { $0 as? NSTextField }.first {
$0.accessibilityLabel() == "Shortcut feedback"