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
+12 -10
View File
@@ -92,8 +92,13 @@ struct ConjureApp {
private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
private let uninstaller = ApplicationUninstaller()
private lazy var launcher = LauncherWindowController(uninstaller: uninstaller)
private lazy var shortcut = GlobalShortcut { [weak self] in self?.launcher.toggle() }
private lazy var settings = SettingsWindowController(shortcut: shortcut)
private lazy var shortcuts = GlobalShortcuts { [weak self] destination in
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 startupRecoveryMessage: String?
@@ -123,13 +128,10 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
if !isLoginLaunch {
launcher.present()
}
do {
try shortcut.start()
} catch {
startupShortcutError = error.localizedDescription
if !isLoginLaunch {
showSettings(nil)
}
let shortcutErrors = shortcuts.start()
if !shortcutErrors.isEmpty {
startupShortcutError = shortcutErrors.joined(separator: "\n")
if !isLoginLaunch { showSettings(nil) }
}
}
@@ -149,7 +151,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
}
func applicationWillTerminate(_ notification: Notification) {
shortcut.stop()
shortcuts.stop()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
@@ -111,6 +111,10 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
@objc func goBack() {
if actions.isShown { actions.close() } else { closed?() }
}
func close() {
actions.close()
closed?()
}
override func cancelOperation(_ sender: Any?) { goBack() }
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 emojiPicker: EmojiPickerController?
private var isCommandOpen: Bool { translation != nil || emojiPicker != nil }
private var activeCommand: Application.Command?
private var pendingPresentation: (() -> Void)?
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
self.history = history
@@ -100,15 +102,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
currencyTask?.cancel()
}
func present() {
(window as? LauncherPanel)?.beginNumberHintSession()
if launcherFrameBeforeReview != nil || isCommandOpen {
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
(window as? LauncherPanel)?.sampleNumberHints()
func present(command: Application.Command? = nil) {
guard !isLaunching, window?.attachedSheet == nil else { return }
if launcherFrameBeforeReview != nil {
pendingPresentation = { [weak self] in self?.present(command: command) }
uninstallReview?.goBack()
return
}
closeCommand()
actions.close()
(window as? LauncherPanel)?.beginNumberHintSession()
let frontmost = NSWorkspace.shared.frontmostApplication
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
previousApplication = frontmost
@@ -124,14 +127,21 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
(window as? LauncherPanel)?.sampleNumberHints()
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
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 }
if window?.isVisible == true, window?.isKeyWindow == true {
if window?.isVisible == true, window?.isKeyWindow == true,
launcherFrameBeforeReview == nil, activeCommand == command {
closeCommand()
actions.close()
dismiss()
} else {
present()
present(command: command)
}
}
@@ -728,6 +738,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
self.openButton.keyEquivalent = "\r"
self.updateOpenButton()
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()
}
private func closeCommand() {
translation?.goBack()
emojiPicker?.close()
}
private func showCommand(_ command: Application.Command) {
guard !isCommandOpen, let content = window?.contentView else { return }
activeCommand = command
let controller: NSViewController
let placeholder: String
let accessibilityLabel: String
@@ -820,6 +839,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
controller.view.removeFromSuperview()
self.translation = nil
self.emojiPicker = nil
self.activeCommand = nil
self.commandBack.isHidden = true
self.commandBack.target = nil
self.searchSymbol.isHidden = false
+34 -19
View File
@@ -4,7 +4,10 @@ import ServiceManagement
@MainActor
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 clearButton = NSButton(title: "Clear", target: nil, action: nil)
private let statusLabel = NSTextField(wrappingLabelWithString: "")
@@ -17,10 +20,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private var recoveryReport: UninstallReport?
var retryUninstallRecovery: (() async -> UninstallReport)?
init(shortcut: GlobalShortcut) {
self.shortcut = shortcut
init(shortcuts: GlobalShortcuts) {
self.shortcuts = shortcuts
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],
backing: .buffered,
defer: false
@@ -69,9 +72,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
}
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)
destinationSelector.addItems(withTitles: GlobalShortcuts.destinations.map(\.title))
destinationSelector.target = self
destinationSelector.action = #selector(changeDestination)
destinationSelector.setAccessibilityLabel("Global shortcut destination")
recorder.bezelStyle = .rounded
recorder.target = self
recorder.action = #selector(beginRecording)
@@ -82,7 +90,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
self?.errorLabel.stringValue = ""
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.setAccessibilityLabel("Use Command Space as the global shortcut")
clearButton.bezelStyle = .rounded
@@ -95,7 +104,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
controls.spacing = 8
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
statusLabel.textColor = .secondaryLabelColor
@@ -139,7 +148,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
loginHeading.textColor = SunsetPalette.text
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
])
stack.orientation = .vertical
@@ -240,11 +249,13 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private func refresh() {
refreshLogin()
commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0
recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut")
let name = shortcut.shortcut?.displayName
recorder.title = recorder.isRecording ? "Type shortcut…" : (name ?? "Record Shortcut…")
recorder.setAccessibilityValue(name ?? "Not set")
if recorder.isRecording {
statusLabel.stringValue = "Global shortcut paused while recording."
statusLabel.stringValue = "All global shortcuts are paused while recording."
} else if shortcut.isRegistered {
statusLabel.stringValue = "Global shortcut is active."
} else if shortcut.shortcut != nil {
@@ -257,7 +268,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
@objc private func beginRecording() {
guard !recorder.isRecording else { return }
errorLabel.stringValue = ""
shortcut.suspend()
shortcuts.suspend()
recorder.isRecording = true
refresh()
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 {
recorder.isRecording = false
errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut."
restoreSavedShortcut()
restoreSavedShortcuts()
refresh()
return
}
@@ -286,11 +297,11 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
recorder.isRecording = false
errorLabel.stringValue = ""
do {
try shortcut.setShortcut(candidate)
try shortcuts.setShortcut(candidate, at: destinationSelector.indexOfSelectedItem)
} catch {
errorLabel.stringValue = error.localizedDescription
if wasRecording { restoreSavedShortcut() }
}
if wasRecording { restoreSavedShortcuts() }
refresh()
}
@@ -298,19 +309,23 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
guard recorder.isRecording else { return }
recorder.isRecording = false
errorLabel.stringValue = ""
restoreSavedShortcut()
restoreSavedShortcuts()
refresh()
}
private func restoreSavedShortcut() {
do {
try shortcut.setShortcut(shortcut.shortcut)
} catch {
let message = "Could not reactivate the saved shortcut: \(error.localizedDescription)"
private func restoreSavedShortcuts() {
let errors = shortcuts.start()
if !errors.isEmpty {
let message = errors.joined(separator: "\n")
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
}
}
@objc private func changeDestination() {
cancelRecording()
refresh()
}
@objc private func useCommandSpace() {
apply(.commandSpace)
}