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
+5 -3
View File
@@ -46,11 +46,13 @@ Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reo
Right-click the launcher background for Settings and Quit. Actions include favorites, resetting usage ranking, revealing applications in Finder, quitting them, and uninstalling them. Right-click the launcher background for Settings and Quit. Actions include favorites, resetting usage ranking, revealing applications in Finder, quitting them, and uninstalling them.
### Global shortcut and launch at login ### Global shortcuts and launch at login
In Settings, choose **Record Shortcut…** and press a combination containing Command, Option, or Control. **Clear** disables it. No global shortcut is assigned by default. In Settings, choose a **Global shortcut destination**: **Applications**, **Conjure Emoji**, or **Conjure Translate**. Choose **Record Shortcut…** and press a combination containing Command, Option, or Control. For example, select **Conjure Emoji** and record Command-Shift-Space to open the emoji picker directly. **Clear** disables only the selected destination's shortcut. No shortcuts are assigned by default; existing launcher bindings are preserved.
For Command-Space, choose **Use ⌘Space**. If it conflicts with Spotlight, Input Sources, or another app, change that binding in System Settings first. Conjure never changes macOS shortcuts for you. The shortcut toggles the launcher while Conjure is running; it does not start a quit app or require Accessibility permission. The Applications shortcut always targets the application launcher, not the last-used tool. Command shortcuts open their selected tool directly. Pressing a shortcut while its destination is already frontmost dismisses Conjure; switching destinations opens a fresh view. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation.
For Command-Space, select **Applications** and choose **Use ⌘Space**. If it conflicts with Spotlight, Input Sources, or another app, change that binding in System Settings first. Conjure never changes macOS shortcuts for you. Shortcuts work while Conjure is running; they do not start a quit app or require Accessibility permission.
Install Conjure in `/Applications`, then enable **Launch at login** in Settings to start it in the background. If macOS requires approval, use **Open Login Items Settings…** to grant it. Install Conjure in `/Applications`, then enable **Launch at login** in Settings to start it in the background. If macOS requires approval, use **Open Login Items Settings…** to grant it.
+12 -10
View File
@@ -92,8 +92,13 @@ struct ConjureApp {
private final class ApplicationDelegate: NSObject, NSApplicationDelegate { private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
private let uninstaller = ApplicationUninstaller() private let uninstaller = ApplicationUninstaller()
private lazy var launcher = LauncherWindowController(uninstaller: uninstaller) private lazy var launcher = LauncherWindowController(uninstaller: uninstaller)
private lazy var shortcut = GlobalShortcut { [weak self] in self?.launcher.toggle() } private lazy var shortcuts = GlobalShortcuts { [weak self] destination in
private lazy var settings = SettingsWindowController(shortcut: shortcut) switch destination {
case .launcher: self?.launcher.toggle()
case .command(let command): self?.launcher.toggle(command: command)
}
}
private lazy var settings = SettingsWindowController(shortcuts: shortcuts)
private var startupShortcutError: String? private var startupShortcutError: String?
private var startupRecoveryMessage: String? private var startupRecoveryMessage: String?
@@ -123,13 +128,10 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
if !isLoginLaunch { if !isLoginLaunch {
launcher.present() launcher.present()
} }
do { let shortcutErrors = shortcuts.start()
try shortcut.start() if !shortcutErrors.isEmpty {
} catch { startupShortcutError = shortcutErrors.joined(separator: "\n")
startupShortcutError = error.localizedDescription if !isLoginLaunch { showSettings(nil) }
if !isLoginLaunch {
showSettings(nil)
}
} }
} }
@@ -149,7 +151,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
shortcut.stop() shortcuts.stop()
} }
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
@@ -111,6 +111,10 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
@objc func goBack() { @objc func goBack() {
if actions.isShown { actions.close() } else { closed?() } if actions.isShown { actions.close() } else { closed?() }
} }
func close() {
actions.close()
closed?()
}
override func cancelOperation(_ sender: Any?) { goBack() } override func cancelOperation(_ sender: Any?) { goBack() }
func inputChanged(preservingSelection: Bool = false) { func inputChanged(preservingSelection: Bool = false) {
+30 -10
View File
@@ -36,6 +36,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var translation: TranslationController? private var translation: TranslationController?
private var emojiPicker: EmojiPickerController? private var emojiPicker: EmojiPickerController?
private var isCommandOpen: Bool { translation != nil || emojiPicker != nil } private var isCommandOpen: Bool { translation != nil || emojiPicker != nil }
private var activeCommand: Application.Command?
private var pendingPresentation: (() -> Void)?
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) { init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
self.history = history self.history = history
@@ -100,15 +102,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
currencyTask?.cancel() currencyTask?.cancel()
} }
func present() { func present(command: Application.Command? = nil) {
(window as? LauncherPanel)?.beginNumberHintSession() guard !isLaunching, window?.attachedSheet == nil else { return }
if launcherFrameBeforeReview != nil || isCommandOpen { if launcherFrameBeforeReview != nil {
showWindow(nil) pendingPresentation = { [weak self] in self?.present(command: command) }
NSApp.activate() uninstallReview?.goBack()
window?.makeKeyAndOrderFront(nil)
(window as? LauncherPanel)?.sampleNumberHints()
return return
} }
closeCommand()
actions.close()
(window as? LauncherPanel)?.beginNumberHintSession()
let frontmost = NSWorkspace.shared.frontmostApplication let frontmost = NSWorkspace.shared.frontmostApplication
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier { if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
previousApplication = frontmost previousApplication = frontmost
@@ -124,14 +127,21 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
(window as? LauncherPanel)?.sampleNumberHints() (window as? LauncherPanel)?.sampleNumberHints()
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent (searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
refreshApplications() refreshApplications()
if let command {
showCommand(command)
history.recordLaunch(of: Application(command: command))
}
} }
func toggle() { func toggle(command: Application.Command? = nil) {
guard !isLaunching, window?.attachedSheet == nil else { return } guard !isLaunching, window?.attachedSheet == nil else { return }
if window?.isVisible == true, window?.isKeyWindow == true { if window?.isVisible == true, window?.isKeyWindow == true,
launcherFrameBeforeReview == nil, activeCommand == command {
closeCommand()
actions.close()
dismiss() dismiss()
} else { } else {
present() present(command: command)
} }
} }
@@ -728,6 +738,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
self.openButton.keyEquivalent = "\r" self.openButton.keyEquivalent = "\r"
self.updateOpenButton() self.updateOpenButton()
self.window?.makeFirstResponder(self.searchField) self.window?.makeFirstResponder(self.searchField)
let pending = self.pendingPresentation
self.pendingPresentation = nil
pending?()
} }
} }
} }
@@ -790,8 +803,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
emojiPicker?.goBack() emojiPicker?.goBack()
} }
private func closeCommand() {
translation?.goBack()
emojiPicker?.close()
}
private func showCommand(_ command: Application.Command) { private func showCommand(_ command: Application.Command) {
guard !isCommandOpen, let content = window?.contentView else { return } guard !isCommandOpen, let content = window?.contentView else { return }
activeCommand = command
let controller: NSViewController let controller: NSViewController
let placeholder: String let placeholder: String
let accessibilityLabel: String let accessibilityLabel: String
@@ -820,6 +839,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
controller.view.removeFromSuperview() controller.view.removeFromSuperview()
self.translation = nil self.translation = nil
self.emojiPicker = nil self.emojiPicker = nil
self.activeCommand = nil
self.commandBack.isHidden = true self.commandBack.isHidden = true
self.commandBack.target = nil self.commandBack.target = nil
self.searchSymbol.isHidden = false self.searchSymbol.isHidden = false
+34 -19
View File
@@ -4,7 +4,10 @@ import ServiceManagement
@MainActor @MainActor
final class SettingsWindowController: NSWindowController, NSWindowDelegate { final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private let shortcut: GlobalShortcut private let shortcuts: GlobalShortcuts
private let destinationSelector = NSPopUpButton(frame: .zero, pullsDown: false)
private let commandSpaceButton = NSButton(title: "Use ⌘Space", target: nil, action: nil)
private var shortcut: GlobalShortcut { shortcuts.bindings[destinationSelector.indexOfSelectedItem] }
private let recorder = ShortcutRecorderButton(title: "Record Shortcut…", target: nil, action: nil) private let recorder = ShortcutRecorderButton(title: "Record Shortcut…", target: nil, action: nil)
private let clearButton = NSButton(title: "Clear", target: nil, action: nil) private let clearButton = NSButton(title: "Clear", target: nil, action: nil)
private let statusLabel = NSTextField(wrappingLabelWithString: "") private let statusLabel = NSTextField(wrappingLabelWithString: "")
@@ -17,10 +20,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private var recoveryReport: UninstallReport? private var recoveryReport: UninstallReport?
var retryUninstallRecovery: (() async -> UninstallReport)? var retryUninstallRecovery: (() async -> UninstallReport)?
init(shortcut: GlobalShortcut) { init(shortcuts: GlobalShortcuts) {
self.shortcut = shortcut self.shortcuts = shortcuts
let window = ShortcutSettingsWindow( let window = ShortcutSettingsWindow(
contentRect: NSRect(x: 0, y: 0, width: 620, height: 640), contentRect: NSRect(x: 0, y: 0, width: 620, height: 690),
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
backing: .buffered, backing: .buffered,
defer: false defer: false
@@ -69,9 +72,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
} }
private func configureContent(in window: NSWindow) { private func configureContent(in window: NSWindow) {
let heading = NSTextField(labelWithString: "Global Shortcut") let heading = NSTextField(labelWithString: "Global Shortcuts")
heading.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) heading.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
destinationSelector.addItems(withTitles: GlobalShortcuts.destinations.map(\.title))
destinationSelector.target = self
destinationSelector.action = #selector(changeDestination)
destinationSelector.setAccessibilityLabel("Global shortcut destination")
recorder.bezelStyle = .rounded recorder.bezelStyle = .rounded
recorder.target = self recorder.target = self
recorder.action = #selector(beginRecording) recorder.action = #selector(beginRecording)
@@ -82,7 +90,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
self?.errorLabel.stringValue = "" self?.errorLabel.stringValue = ""
self?.statusLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels." self?.statusLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels."
} }
let commandSpaceButton = NSButton(title: "Use ⌘Space", target: self, action: #selector(useCommandSpace)) commandSpaceButton.target = self
commandSpaceButton.action = #selector(useCommandSpace)
commandSpaceButton.bezelStyle = .rounded commandSpaceButton.bezelStyle = .rounded
commandSpaceButton.setAccessibilityLabel("Use Command Space as the global shortcut") commandSpaceButton.setAccessibilityLabel("Use Command Space as the global shortcut")
clearButton.bezelStyle = .rounded clearButton.bezelStyle = .rounded
@@ -95,7 +104,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
controls.spacing = 8 controls.spacing = 8
let instructions = NSTextField(wrappingLabelWithString: let instructions = NSTextField(wrappingLabelWithString:
"Click the shortcut button, then press a key with Command, Option, or Control. Press Escape to cancel. The global shortcut is paused while recording." "Choose a destination, click the shortcut button, then press a key with Command, Option, or Control. Press Escape to cancel. All global shortcuts are paused while recording."
) )
instructions.textColor = .secondaryLabelColor instructions.textColor = .secondaryLabelColor
statusLabel.textColor = .secondaryLabelColor statusLabel.textColor = .secondaryLabelColor
@@ -139,7 +148,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
loginHeading.textColor = SunsetPalette.text loginHeading.textColor = SunsetPalette.text
let stack = NSStackView(views: [ let stack = NSStackView(views: [
heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings, heading, destinationSelector, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton
]) ])
stack.orientation = .vertical stack.orientation = .vertical
@@ -240,11 +249,13 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private func refresh() { private func refresh() {
refreshLogin() refreshLogin()
commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0
recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut")
let name = shortcut.shortcut?.displayName let name = shortcut.shortcut?.displayName
recorder.title = recorder.isRecording ? "Type shortcut…" : (name ?? "Record Shortcut…") recorder.title = recorder.isRecording ? "Type shortcut…" : (name ?? "Record Shortcut…")
recorder.setAccessibilityValue(name ?? "Not set") recorder.setAccessibilityValue(name ?? "Not set")
if recorder.isRecording { if recorder.isRecording {
statusLabel.stringValue = "Global shortcut paused while recording." statusLabel.stringValue = "All global shortcuts are paused while recording."
} else if shortcut.isRegistered { } else if shortcut.isRegistered {
statusLabel.stringValue = "Global shortcut is active." statusLabel.stringValue = "Global shortcut is active."
} else if shortcut.shortcut != nil { } else if shortcut.shortcut != nil {
@@ -257,7 +268,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
@objc private func beginRecording() { @objc private func beginRecording() {
guard !recorder.isRecording else { return } guard !recorder.isRecording else { return }
errorLabel.stringValue = "" errorLabel.stringValue = ""
shortcut.suspend() shortcuts.suspend()
recorder.isRecording = true recorder.isRecording = true
refresh() refresh()
if window?.makeFirstResponder(recorder) != true { if window?.makeFirstResponder(recorder) != true {
@@ -274,7 +285,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
guard let candidate = KeyboardShortcut(keyCode: event.keyCode, modifiers: event.modifierFlags) else { guard let candidate = KeyboardShortcut(keyCode: event.keyCode, modifiers: event.modifierFlags) else {
recorder.isRecording = false recorder.isRecording = false
errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut." errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut."
restoreSavedShortcut() restoreSavedShortcuts()
refresh() refresh()
return return
} }
@@ -286,11 +297,11 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
recorder.isRecording = false recorder.isRecording = false
errorLabel.stringValue = "" errorLabel.stringValue = ""
do { do {
try shortcut.setShortcut(candidate) try shortcuts.setShortcut(candidate, at: destinationSelector.indexOfSelectedItem)
} catch { } catch {
errorLabel.stringValue = error.localizedDescription errorLabel.stringValue = error.localizedDescription
if wasRecording { restoreSavedShortcut() }
} }
if wasRecording { restoreSavedShortcuts() }
refresh() refresh()
} }
@@ -298,19 +309,23 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
guard recorder.isRecording else { return } guard recorder.isRecording else { return }
recorder.isRecording = false recorder.isRecording = false
errorLabel.stringValue = "" errorLabel.stringValue = ""
restoreSavedShortcut() restoreSavedShortcuts()
refresh() refresh()
} }
private func restoreSavedShortcut() { private func restoreSavedShortcuts() {
do { let errors = shortcuts.start()
try shortcut.setShortcut(shortcut.shortcut) if !errors.isEmpty {
} catch { let message = errors.joined(separator: "\n")
let message = "Could not reactivate the saved shortcut: \(error.localizedDescription)"
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)" errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
} }
} }
@objc private func changeDestination() {
cancelRecording()
refresh()
}
@objc private func useCommandSpace() { @objc private func useCommandSpace() {
apply(.commandSpace) apply(.commandSpace)
} }
+19 -8
View File
@@ -6,15 +6,20 @@ public final class GlobalShortcut {
public private(set) var shortcut: KeyboardShortcut? public private(set) var shortcut: KeyboardShortcut?
public private(set) var isRegistered = false public private(set) var isRegistered = false
private static let storageKey = "globalActivationShortcut" private let storageKey: String
private let defaults: UserDefaults private let defaults: UserDefaults
private let action: @MainActor () -> Void private let action: @MainActor () -> Void
private let native = NativeHotKey() private let native = NativeHotKey()
public init(defaults: UserDefaults = .standard, action: @escaping @MainActor () -> Void) { public init(
defaults: UserDefaults = .standard,
storageKey: String = "globalActivationShortcut",
action: @escaping @MainActor () -> Void
) {
self.defaults = defaults self.defaults = defaults
self.storageKey = storageKey
self.action = action self.action = action
shortcut = KeyboardShortcut(storedValue: defaults.object(forKey: Self.storageKey)) shortcut = KeyboardShortcut(storedValue: defaults.object(forKey: storageKey))
native.owner = self native.owner = self
} }
@@ -24,7 +29,7 @@ public final class GlobalShortcut {
public func start() throws { public func start() throws {
guard !isRegistered else { return } guard !isRegistered else { return }
if let saved = defaults.object(forKey: Self.storageKey) { if let saved = defaults.object(forKey: storageKey) {
guard let restored = KeyboardShortcut(storedValue: saved) else { guard let restored = KeyboardShortcut(storedValue: saved) else {
throw ShortcutError.invalidSavedShortcut throw ShortcutError.invalidSavedShortcut
} }
@@ -37,11 +42,11 @@ public final class GlobalShortcut {
if candidate == shortcut, isRegistered { return } if candidate == shortcut, isRegistered { return }
if let candidate { if let candidate {
try activate(candidate) try activate(candidate)
defaults.set(candidate.storedValue, forKey: Self.storageKey) defaults.set(candidate.storedValue, forKey: storageKey)
} else { } else {
try native.unregister() try native.unregister()
isRegistered = false isRegistered = false
defaults.removeObject(forKey: Self.storageKey) defaults.removeObject(forKey: storageKey)
} }
shortcut = candidate shortcut = candidate
} }
@@ -91,17 +96,23 @@ public final class GlobalShortcut {
} }
} }
private enum ShortcutError: LocalizedError { enum ShortcutError: LocalizedError {
case invalidSavedShortcut case invalidSavedShortcut
case systemConflict(String) case systemConflict(String)
case inAppConflict(shortcut: String, action: String)
case duplicate(shortcut: String, destination: String)
case native(operation: String, status: OSStatus) case native(operation: String, status: OSStatus)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .invalidSavedShortcut: case .invalidSavedShortcut:
return "The saved activation shortcut is invalid. Choose a new shortcut or clear it in Conjure Settings." return "The saved shortcut is invalid. Choose a new shortcut or clear it in Conjure Settings."
case .systemConflict(let shortcut): case .systemConflict(let shortcut):
return "\(shortcut) is already an enabled macOS shortcut. Free it in System Settings → Keyboard → Keyboard Shortcuts (check Spotlight and Input Sources), or choose another shortcut. Conjure will not change your system settings." return "\(shortcut) is already an enabled macOS shortcut. Free it in System Settings → Keyboard → Keyboard Shortcuts (check Spotlight and Input Sources), or choose another shortcut. Conjure will not change your system settings."
case .inAppConflict(let shortcut, let action):
return "\(shortcut) is already used by Conjure for \(action). Choose another shortcut."
case .duplicate(let shortcut, let destination):
return "\(shortcut) is already assigned to \(destination). Clear that binding or choose another shortcut."
case .native(let operation, let status): case .native(let operation, let status):
if status == eventHotKeyExistsErr { if status == eventHotKeyExistsErr {
return "That shortcut is already registered by another app. Change or disable the other app’s shortcut, or choose another one. For Command-Space, also check Spotlight and Input Sources in System Settings → Keyboard → Keyboard Shortcuts." return "That shortcut is already registered by another app. Change or disable the other app’s shortcut, or choose another one. For Command-Space, also check Spotlight and Input Sources in System Settings → Keyboard → Keyboard Shortcuts."
+120
View File
@@ -0,0 +1,120 @@
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 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 suspend() {
isSuspended = true
bindings.forEach { $0.suspend() }
}
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 "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 == .control {
switch key {
case "h", "j", "k", "l": return "launcher and emoji navigation"
default: break
}
}
return nil
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ public struct KeyboardShortcut: Equatable, Sendable {
} }
@MainActor @MainActor
private var keyName: String { var keyName: String {
switch keyCode { switch keyCode {
case 36: return "Return" case 36: return "Return"
case 48: return "Tab" case 48: return "Tab"
+1
View File
@@ -1,3 +1,4 @@
- [x] add an emoji picker inspired raycast's emoji picker - [x] add an emoji picker inspired raycast's emoji picker
- [ ] make the star/favorite symbol a bit smaller when prefixed launcher list entries (e.g. "<star> Application", or "<star> Command")
- [ ] add a notes app inspired by raycast notes - [ ] add a notes app inspired by raycast notes
- [ ] improve on the conjure translation command - [ ] improve on the conjure translation command
@@ -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 suite = "Conjure.ToolFeedbackTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite)) let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: 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 window = try #require(controller.window)
let views = descendants(try #require(window.contentView)) let views = descendants(try #require(window.contentView))
let recorder = try #require(views.compactMap { $0 as? NSButton }.first { 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 { let error = try #require(views.compactMap { $0 as? NSTextField }.first {
$0.accessibilityLabel() == "Shortcut feedback" $0.accessibilityLabel() == "Shortcut feedback"