feat: configure a persistent global launcher shortcut
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Conjure
|
# Conjure
|
||||||
|
|
||||||
A native macOS application launcher. Open it normally, search for an application, and press Return to open it or bring its existing instance forward. Global shortcuts are deliberately deferred.
|
A native macOS application launcher. Open it normally or activate it with a configured global shortcut, search for an application, and press Return to open it or bring its existing instance forward.
|
||||||
|
|
||||||
## Build, run, and install
|
## Build, run, and install
|
||||||
|
|
||||||
@@ -33,11 +33,22 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to
|
|||||||
- Type to filter applications; the first match is selected automatically.
|
- Type to filter applications; the first match is selected automatically.
|
||||||
- Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text.
|
- Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text.
|
||||||
- Return, keypad Enter, the Open Application button, or a double-click opens the selection.
|
- Return, keypad Enter, the Open Application button, or a double-click opens the selection.
|
||||||
- Escape, Command-W, or switching focus away dismisses the launcher.
|
- Escape or Command-W dismisses the launcher and restores the previously active application. Switching focus away dismisses it without stealing focus back.
|
||||||
- Reopen from Finder or the Dock to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it.
|
- Reopen from Finder or the Dock to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it.
|
||||||
- Standard macOS text-editing shortcuts work in the search field.
|
- Standard macOS text-editing shortcuts work in the search field.
|
||||||
|
- Command-comma opens the native Settings window.
|
||||||
- Drag the panel background to move it.
|
- Drag the panel background to move it.
|
||||||
|
|
||||||
|
## Global shortcut
|
||||||
|
|
||||||
|
Open **Conjure → Settings…** (`⌘,`), click **Record Shortcut…**, and press a combination containing Command, Option, or Control. Shift can be added. The shortcut is saved locally and restored when Conjure starts. No shortcut is claimed until you choose one; **Clear** disables it.
|
||||||
|
|
||||||
|
For Command-Space, click **Use ⌘Space**. If it conflicts, open **System Settings → Keyboard → Keyboard Shortcuts** and disable or change the conflicting Spotlight or Input Sources binding, or another app's shortcut, then try again. The Settings window includes an **Open Keyboard Settings…** button. Conjure reports conflicts rather than modifying macOS preferences, and a failed change preserves your working shortcut.
|
||||||
|
|
||||||
|
The global shortcut shows the launcher with an empty query and search focused. Press it again while the launcher is focused to dismiss it and return to the previously active app. If Conjure is running in the background, the shortcut brings it forward. Recording temporarily pauses global activation; Escape, moving focus out of the recorder, or closing Settings cancels recording and restores the saved binding.
|
||||||
|
|
||||||
|
Activation uses the native macOS hotkey API, without an Accessibility permission or a global keyboard monitor. It works only while Conjure is running; it does not launch a quit app or enable launch at login.
|
||||||
|
|
||||||
## Discovery and search
|
## Discovery and search
|
||||||
|
|
||||||
Applications are discovered in `/Applications`, `/System/Applications`, `/System/Library/CoreServices/Applications`, and `~/Applications`, including nested folders. Discovery runs off the UI thread and refreshes whenever the launcher is presented. Hidden items, invalid bundles, and helpers inside application packages are excluded; symlinked applications are deduplicated by their canonical paths.
|
Applications are discovered in `/Applications`, `/System/Applications`, `/System/Library/CoreServices/Applications`, and `~/Applications`, including nested folders. Discovery runs off the UI thread and refreshes whenever the launcher is presented. Hidden items, invalid bundles, and helpers inside application packages are excluded; symlinked applications are deduplicated by their canonical paths.
|
||||||
@@ -60,8 +71,8 @@ The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView`
|
|||||||
|
|
||||||
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection.
|
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection.
|
||||||
|
|
||||||
- `Sources/Conjure`: application lifecycle, native window, and application launching.
|
- `Sources/Conjure`: application lifecycle, launcher and Settings windows, and application launching.
|
||||||
- `Sources/ConjureKit`: filesystem discovery and search ranking.
|
- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, and global shortcut registration.
|
||||||
- `Tests/ConjureKitTests`: deterministic discovery and ranking checks.
|
- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, shortcut validation, and installation safety checks.
|
||||||
|
|
||||||
The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)).
|
The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)).
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -8,7 +9,7 @@ struct ConjureApp {
|
|||||||
let delegate = ApplicationDelegate()
|
let delegate = ApplicationDelegate()
|
||||||
application.delegate = delegate
|
application.delegate = delegate
|
||||||
application.setActivationPolicy(.regular)
|
application.setActivationPolicy(.regular)
|
||||||
application.mainMenu = makeMainMenu()
|
application.mainMenu = makeMainMenu(delegate: delegate)
|
||||||
|
|
||||||
// NSApplication holds its delegate weakly.
|
// NSApplication holds its delegate weakly.
|
||||||
withExtendedLifetime(delegate) {
|
withExtendedLifetime(delegate) {
|
||||||
@@ -16,7 +17,7 @@ struct ConjureApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func makeMainMenu() -> NSMenu {
|
private static func makeMainMenu(delegate: ApplicationDelegate) -> NSMenu {
|
||||||
let menu = NSMenu()
|
let menu = NSMenu()
|
||||||
let applicationItem = NSMenuItem(title: "Conjure", action: nil, keyEquivalent: "")
|
let applicationItem = NSMenuItem(title: "Conjure", action: nil, keyEquivalent: "")
|
||||||
let applicationMenu = NSMenu(title: "Conjure")
|
let applicationMenu = NSMenu(title: "Conjure")
|
||||||
@@ -28,6 +29,12 @@ struct ConjureApp {
|
|||||||
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)),
|
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)),
|
||||||
keyEquivalent: ""
|
keyEquivalent: ""
|
||||||
)
|
)
|
||||||
|
let settings = applicationMenu.addItem(
|
||||||
|
withTitle: "Settings…",
|
||||||
|
action: #selector(ApplicationDelegate.showSettings(_:)),
|
||||||
|
keyEquivalent: ","
|
||||||
|
)
|
||||||
|
settings.target = delegate
|
||||||
applicationMenu.addItem(.separator())
|
applicationMenu.addItem(.separator())
|
||||||
applicationMenu.addItem(
|
applicationMenu.addItem(
|
||||||
withTitle: "Hide Conjure",
|
withTitle: "Hide Conjure",
|
||||||
@@ -84,9 +91,16 @@ struct ConjureApp {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||||
private lazy var launcher = LauncherWindowController()
|
private lazy var launcher = LauncherWindowController()
|
||||||
|
private lazy var shortcut = GlobalShortcut { [weak self] in self?.launcher.toggle() }
|
||||||
|
private lazy var settings = SettingsWindowController(shortcut: shortcut)
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
launcher.present()
|
launcher.present()
|
||||||
|
do {
|
||||||
|
try shortcut.start()
|
||||||
|
} catch {
|
||||||
|
settings.present(error: error.localizedDescription)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
|
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
|
||||||
@@ -94,6 +108,14 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func showSettings(_ sender: Any?) {
|
||||||
|
settings.present()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
shortcut.stop()
|
||||||
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
private var locationLabels: [URL: String] = [:]
|
private var locationLabels: [URL: String] = [:]
|
||||||
private var discoveryTask: Task<Void, Never>?
|
private var discoveryTask: Task<Void, Never>?
|
||||||
private var isLaunching = false
|
private var isLaunching = false
|
||||||
|
private var previousApplication: NSRunningApplication?
|
||||||
|
|
||||||
init(history: LaunchHistory = LaunchHistory()) {
|
init(history: LaunchHistory = LaunchHistory()) {
|
||||||
self.history = history
|
self.history = history
|
||||||
@@ -36,6 +37,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
configureContent(in: window)
|
configureContent(in: window)
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) }
|
window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) }
|
||||||
|
window.dismiss = { [weak self] in self?.dismiss() }
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
@@ -43,6 +45,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func present() {
|
func present() {
|
||||||
|
let frontmost = NSWorkspace.shared.frontmostApplication
|
||||||
|
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
|
||||||
|
previousApplication = frontmost
|
||||||
|
}
|
||||||
searchField.stringValue = ""
|
searchField.stringValue = ""
|
||||||
updateResults()
|
updateResults()
|
||||||
window?.deminiaturize(nil)
|
window?.deminiaturize(nil)
|
||||||
@@ -54,9 +60,29 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
refreshApplications()
|
refreshApplications()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toggle() {
|
||||||
|
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||||||
|
if window?.isVisible == true, window?.isKeyWindow == true {
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
present()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismiss(restoringFocus: Bool = true) {
|
||||||
|
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
|
||||||
|
let previous = previousApplication
|
||||||
|
previousApplication = nil
|
||||||
|
window?.orderOut(nil)
|
||||||
|
if restoringFocus, let previous, !previous.isTerminated {
|
||||||
|
NSApp.yieldActivation(to: previous)
|
||||||
|
previous.activate(from: .current, options: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func windowDidResignKey(_ notification: Notification) {
|
func windowDidResignKey(_ notification: Notification) {
|
||||||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||||||
window?.orderOut(nil)
|
dismiss(restoringFocus: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureContent(in window: NSWindow) {
|
private func configureContent(in window: NSWindow) {
|
||||||
@@ -120,7 +146,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
tableView.target = self
|
tableView.target = self
|
||||||
tableView.doubleAction = #selector(openSelection)
|
tableView.doubleAction = #selector(openSelection)
|
||||||
tableView.openSelection = { [weak self] in self?.openSelection() }
|
tableView.openSelection = { [weak self] in self?.openSelection() }
|
||||||
tableView.dismiss = { [weak self] in self?.window?.orderOut(nil) }
|
tableView.dismiss = { [weak self] in self?.dismiss() }
|
||||||
tableView.editSearch = { [weak self] event in
|
tableView.editSearch = { [weak self] event in
|
||||||
guard let self, let window = self.window else { return }
|
guard let self, let window = self.window else { return }
|
||||||
window.makeFirstResponder(self.searchField)
|
window.makeFirstResponder(self.searchField)
|
||||||
@@ -295,7 +321,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
case #selector(NSResponder.insertNewline(_:)):
|
case #selector(NSResponder.insertNewline(_:)):
|
||||||
openSelection()
|
openSelection()
|
||||||
case #selector(NSResponder.cancelOperation(_:)):
|
case #selector(NSResponder.cancelOperation(_:)):
|
||||||
window?.orderOut(nil)
|
dismiss()
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -397,7 +423,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
do {
|
do {
|
||||||
try await AppLauncher.launch(application.url)
|
try await AppLauncher.launch(application.url)
|
||||||
history.recordLaunch(of: application)
|
history.recordLaunch(of: application)
|
||||||
window?.orderOut(nil)
|
dismiss(restoringFocus: false)
|
||||||
} catch {
|
} catch {
|
||||||
let alert = NSAlert(error: error)
|
let alert = NSAlert(error: error)
|
||||||
alert.messageText = "Couldn’t open \(application.name)"
|
alert.messageText = "Couldn’t open \(application.name)"
|
||||||
@@ -445,6 +471,7 @@ private final class LauncherPanel: NSPanel {
|
|||||||
override var canBecomeKey: Bool { true }
|
override var canBecomeKey: Bool { true }
|
||||||
override var canBecomeMain: Bool { true }
|
override var canBecomeMain: Bool { true }
|
||||||
var navigateSelection: ((Int) -> Void)?
|
var navigateSelection: ((Int) -> Void)?
|
||||||
|
var dismiss: (() -> Void)?
|
||||||
|
|
||||||
override func sendEvent(_ event: NSEvent) {
|
override func sendEvent(_ event: NSEvent) {
|
||||||
if event.type == .keyDown, attachedSheet == nil,
|
if event.type == .keyDown, attachedSheet == nil,
|
||||||
@@ -464,7 +491,7 @@ private final class LauncherPanel: NSPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func performClose(_ sender: Any?) {
|
override func performClose(_ sender: Any?) {
|
||||||
close()
|
dismiss?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||||
|
private let shortcut: GlobalShortcut
|
||||||
|
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: "")
|
||||||
|
private let errorLabel = NSTextField(wrappingLabelWithString: "")
|
||||||
|
|
||||||
|
init(shortcut: GlobalShortcut) {
|
||||||
|
self.shortcut = shortcut
|
||||||
|
let window = ShortcutSettingsWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 580, height: 520),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.title = "Conjure Settings"
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.center()
|
||||||
|
super.init(window: window)
|
||||||
|
window.delegate = self
|
||||||
|
window.recorder = recorder
|
||||||
|
configureContent(in: window)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("Conjure creates its settings window programmatically")
|
||||||
|
}
|
||||||
|
|
||||||
|
func present(error: String? = nil) {
|
||||||
|
cancelRecording()
|
||||||
|
if let error {
|
||||||
|
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? error : "\(error)\n\(errorLabel.stringValue)"
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
window?.deminiaturize(nil)
|
||||||
|
showWindow(nil)
|
||||||
|
NSApp.activate()
|
||||||
|
window?.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowDidResignKey(_ notification: Notification) {
|
||||||
|
cancelRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowWillClose(_ notification: Notification) {
|
||||||
|
cancelRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureContent(in window: NSWindow) {
|
||||||
|
let heading = NSTextField(labelWithString: "Global Shortcut")
|
||||||
|
heading.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
|
||||||
|
|
||||||
|
recorder.bezelStyle = .rounded
|
||||||
|
recorder.target = self
|
||||||
|
recorder.action = #selector(beginRecording)
|
||||||
|
recorder.setAccessibilityLabel("Record global shortcut")
|
||||||
|
recorder.capture = { [weak self] event in self?.capture(event) }
|
||||||
|
recorder.cancel = { [weak self] in self?.cancelRecording() }
|
||||||
|
recorder.modifiersChanged = { [weak self] in
|
||||||
|
self?.errorLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels."
|
||||||
|
}
|
||||||
|
let commandSpaceButton = NSButton(title: "Use ⌘Space", target: self, action: #selector(useCommandSpace))
|
||||||
|
commandSpaceButton.bezelStyle = .rounded
|
||||||
|
commandSpaceButton.setAccessibilityLabel("Use Command Space as the global shortcut")
|
||||||
|
clearButton.bezelStyle = .rounded
|
||||||
|
clearButton.target = self
|
||||||
|
clearButton.action = #selector(clearShortcut)
|
||||||
|
clearButton.setAccessibilityLabel("Clear global shortcut")
|
||||||
|
let controls = NSStackView(views: [recorder, commandSpaceButton, clearButton])
|
||||||
|
controls.orientation = .horizontal
|
||||||
|
controls.alignment = .centerY
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
instructions.textColor = .secondaryLabelColor
|
||||||
|
statusLabel.textColor = .secondaryLabelColor
|
||||||
|
statusLabel.setAccessibilityLabel("Global shortcut registration status")
|
||||||
|
errorLabel.textColor = .systemRed
|
||||||
|
errorLabel.setAccessibilityLabel("Shortcut feedback")
|
||||||
|
|
||||||
|
let spotlight = NSTextField(wrappingLabelWithString:
|
||||||
|
"To use ⌘Space, first free it in System Settings → Keyboard → Keyboard Shortcuts → Spotlight: turn off or change “Show Spotlight search.” Check Input Sources for other reserved shortcuts. Conjure never changes these settings."
|
||||||
|
)
|
||||||
|
spotlight.textColor = .secondaryLabelColor
|
||||||
|
let keyboardSettings = NSButton(
|
||||||
|
title: "Open Keyboard Settings…", target: self, action: #selector(openKeyboardSettings)
|
||||||
|
)
|
||||||
|
keyboardSettings.bezelStyle = .rounded
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [
|
||||||
|
heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings
|
||||||
|
])
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.alignment = .leading
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
let content = NSView()
|
||||||
|
window.contentView = content
|
||||||
|
content.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
|
||||||
|
stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
|
||||||
|
stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24),
|
||||||
|
recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190)
|
||||||
|
])
|
||||||
|
for label in [instructions, statusLabel, errorLabel, spotlight] {
|
||||||
|
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh() {
|
||||||
|
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."
|
||||||
|
} else if shortcut.isRegistered {
|
||||||
|
statusLabel.stringValue = "Global shortcut is active."
|
||||||
|
} else if shortcut.shortcut != nil {
|
||||||
|
statusLabel.stringValue = "This shortcut is saved but is not active."
|
||||||
|
} else {
|
||||||
|
statusLabel.stringValue = "No global shortcut is configured."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func beginRecording() {
|
||||||
|
guard !recorder.isRecording else { return }
|
||||||
|
errorLabel.stringValue = ""
|
||||||
|
shortcut.suspend()
|
||||||
|
recorder.isRecording = true
|
||||||
|
refresh()
|
||||||
|
if window?.makeFirstResponder(recorder) != true {
|
||||||
|
cancelRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func capture(_ event: NSEvent) {
|
||||||
|
guard !event.isARepeat else { return }
|
||||||
|
if event.keyCode == 53, event.modifierFlags.intersection([.command, .option, .control]).isEmpty {
|
||||||
|
cancelRecording()
|
||||||
|
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."
|
||||||
|
restoreSavedShortcut()
|
||||||
|
refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apply(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(_ candidate: KeyboardShortcut?) {
|
||||||
|
let wasRecording = recorder.isRecording
|
||||||
|
recorder.isRecording = false
|
||||||
|
errorLabel.stringValue = ""
|
||||||
|
do {
|
||||||
|
try shortcut.setShortcut(candidate)
|
||||||
|
} catch {
|
||||||
|
errorLabel.stringValue = error.localizedDescription
|
||||||
|
if wasRecording { restoreSavedShortcut() }
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelRecording() {
|
||||||
|
guard recorder.isRecording else { return }
|
||||||
|
recorder.isRecording = false
|
||||||
|
errorLabel.stringValue = ""
|
||||||
|
restoreSavedShortcut()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreSavedShortcut() {
|
||||||
|
do {
|
||||||
|
try shortcut.setShortcut(shortcut.shortcut)
|
||||||
|
} catch {
|
||||||
|
let message = "Could not reactivate the saved shortcut: \(error.localizedDescription)"
|
||||||
|
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func useCommandSpace() {
|
||||||
|
apply(.commandSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func clearShortcut() {
|
||||||
|
apply(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func openKeyboardSettings() {
|
||||||
|
cancelRecording()
|
||||||
|
let url = URL(string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")!
|
||||||
|
if !NSWorkspace.shared.open(url) {
|
||||||
|
errorLabel.stringValue = "Could not open Keyboard settings. Open System Settings → Keyboard manually."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class ShortcutSettingsWindow: NSWindow {
|
||||||
|
weak var recorder: ShortcutRecorderButton?
|
||||||
|
|
||||||
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||||
|
if let recorder, recorder.isRecording, firstResponder === recorder {
|
||||||
|
return recorder.performKeyEquivalent(with: event)
|
||||||
|
}
|
||||||
|
return super.performKeyEquivalent(with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class ShortcutRecorderButton: NSButton {
|
||||||
|
var isRecording = false
|
||||||
|
var capture: ((NSEvent) -> Void)?
|
||||||
|
var cancel: (() -> Void)?
|
||||||
|
var modifiersChanged: (() -> Void)?
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||||
|
guard isRecording, window?.firstResponder === self else {
|
||||||
|
return super.performKeyEquivalent(with: event)
|
||||||
|
}
|
||||||
|
keyDown(with: event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
guard isRecording else {
|
||||||
|
super.keyDown(with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let activationModifiers: NSEvent.ModifierFlags = [.command, .option, .control]
|
||||||
|
if event.keyCode == 48, event.modifierFlags.intersection(activationModifiers).isEmpty {
|
||||||
|
if event.modifierFlags.contains(.shift) {
|
||||||
|
window?.selectPreviousKeyView(nil)
|
||||||
|
} else {
|
||||||
|
window?.selectNextKeyView(nil)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
capture?(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func flagsChanged(with event: NSEvent) {
|
||||||
|
if isRecording { modifiersChanged?() } else { super.flagsChanged(with: event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func resignFirstResponder() -> Bool {
|
||||||
|
let resigned = super.resignFirstResponder()
|
||||||
|
if resigned, isRecording { cancel?() }
|
||||||
|
return resigned
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import AppKit
|
||||||
|
import Carbon
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class GlobalShortcut {
|
||||||
|
public private(set) var shortcut: KeyboardShortcut?
|
||||||
|
public private(set) var isRegistered = false
|
||||||
|
|
||||||
|
private static let storageKey = "globalActivationShortcut"
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let action: @MainActor () -> Void
|
||||||
|
private let native = NativeHotKey()
|
||||||
|
|
||||||
|
public init(defaults: UserDefaults = .standard, action: @escaping @MainActor () -> Void) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.action = action
|
||||||
|
shortcut = KeyboardShortcut(storedValue: defaults.object(forKey: Self.storageKey))
|
||||||
|
native.owner = self
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
native.disposeOnMainThread()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func start() throws {
|
||||||
|
guard !isRegistered else { return }
|
||||||
|
if let saved = defaults.object(forKey: Self.storageKey) {
|
||||||
|
guard let restored = KeyboardShortcut(storedValue: saved) else {
|
||||||
|
throw ShortcutError.invalidSavedShortcut
|
||||||
|
}
|
||||||
|
try activate(restored)
|
||||||
|
shortcut = restored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setShortcut(_ candidate: KeyboardShortcut?) throws {
|
||||||
|
if candidate == shortcut, isRegistered { return }
|
||||||
|
if let candidate {
|
||||||
|
try activate(candidate)
|
||||||
|
defaults.set(candidate.storedValue, forKey: Self.storageKey)
|
||||||
|
} else {
|
||||||
|
try native.unregister()
|
||||||
|
isRegistered = false
|
||||||
|
defaults.removeObject(forKey: Self.storageKey)
|
||||||
|
}
|
||||||
|
shortcut = candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
public func suspend() {
|
||||||
|
do {
|
||||||
|
try native.unregister()
|
||||||
|
isRegistered = false
|
||||||
|
} catch {
|
||||||
|
NSLog("Conjure could not suspend its shortcut: %@", error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
native.dispose()
|
||||||
|
isRegistered = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func activateFromEvent() {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activate(_ candidate: KeyboardShortcut) throws {
|
||||||
|
try Self.rejectSystemConflict(candidate)
|
||||||
|
try native.register(candidate)
|
||||||
|
isRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func rejectSystemConflict(_ candidate: KeyboardShortcut) throws {
|
||||||
|
var copied: Unmanaged<CFArray>?
|
||||||
|
let status = CopySymbolicHotKeys(&copied)
|
||||||
|
let result = copied?.takeRetainedValue()
|
||||||
|
guard status == noErr, let result else {
|
||||||
|
throw ShortcutError.native(operation: "read macOS keyboard shortcuts", status: status)
|
||||||
|
}
|
||||||
|
let hotKeys = result as NSArray
|
||||||
|
for case let record as NSDictionary in hotKeys {
|
||||||
|
guard let enabled = record[kHISymbolicHotKeyEnabled] as? NSNumber, enabled.boolValue,
|
||||||
|
let code = record[kHISymbolicHotKeyCode] as? NSNumber,
|
||||||
|
let modifiers = record[kHISymbolicHotKeyModifiers] as? NSNumber else { continue }
|
||||||
|
let relevantModifiers = UInt32(cmdKey | optionKey | controlKey | shiftKey)
|
||||||
|
if code.uint16Value == candidate.keyCode,
|
||||||
|
modifiers.uint32Value & relevantModifiers == candidate.carbonModifiers {
|
||||||
|
throw ShortcutError.systemConflict(candidate.displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ShortcutError: LocalizedError {
|
||||||
|
case invalidSavedShortcut
|
||||||
|
case systemConflict(String)
|
||||||
|
case native(operation: String, status: OSStatus)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidSavedShortcut:
|
||||||
|
return "The saved activation shortcut is invalid. Choose a new shortcut or clear it in Conjure Settings."
|
||||||
|
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."
|
||||||
|
case .native(let operation, let status):
|
||||||
|
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 "Conjure could not \(operation) (macOS error \(status)). Choose another shortcut or try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carbon is main-thread-only. This separate lifetime lets a nonisolated Swift
|
||||||
|
// deinit enqueue teardown without capturing a dying owner or dangling C context.
|
||||||
|
private final class NativeHotKey: @unchecked Sendable {
|
||||||
|
@MainActor weak var owner: GlobalShortcut?
|
||||||
|
@MainActor private var handler: EventHandlerRef?
|
||||||
|
@MainActor private var hotKey: EventHotKeyRef?
|
||||||
|
@MainActor private var identifier: UInt32 = 0
|
||||||
|
@MainActor private var isPressed = false
|
||||||
|
@MainActor private static var nextIdentifier: UInt32 = 0
|
||||||
|
private static let signature: OSType = 0x434E4A52 // CNJR
|
||||||
|
|
||||||
|
@MainActor init() {}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func register(_ shortcut: KeyboardShortcut) throws {
|
||||||
|
precondition(Thread.isMainThread)
|
||||||
|
try installHandler()
|
||||||
|
Self.nextIdentifier &+= 1
|
||||||
|
if Self.nextIdentifier == 0 { Self.nextIdentifier = 1 }
|
||||||
|
let next = Self.nextIdentifier
|
||||||
|
var replacement: EventHotKeyRef?
|
||||||
|
let status = RegisterEventHotKey(
|
||||||
|
UInt32(shortcut.keyCode), shortcut.carbonModifiers,
|
||||||
|
EventHotKeyID(signature: Self.signature, id: next),
|
||||||
|
GetApplicationEventTarget(), UInt32(kEventHotKeyExclusive), &replacement
|
||||||
|
)
|
||||||
|
guard status == noErr, let replacement else {
|
||||||
|
throw ShortcutError.native(operation: "register the activation shortcut", status: status)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try unregister()
|
||||||
|
} catch {
|
||||||
|
UnregisterEventHotKey(replacement)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
hotKey = replacement
|
||||||
|
identifier = next
|
||||||
|
isPressed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func unregister() throws {
|
||||||
|
precondition(Thread.isMainThread)
|
||||||
|
guard let hotKey else { return }
|
||||||
|
let status = UnregisterEventHotKey(hotKey)
|
||||||
|
guard status == noErr else {
|
||||||
|
throw ShortcutError.native(operation: "release the previous activation shortcut", status: status)
|
||||||
|
}
|
||||||
|
self.hotKey = nil
|
||||||
|
identifier = 0
|
||||||
|
isPressed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func dispose() {
|
||||||
|
precondition(Thread.isMainThread)
|
||||||
|
if let hotKey { UnregisterEventHotKey(hotKey) }
|
||||||
|
hotKey = nil
|
||||||
|
identifier = 0
|
||||||
|
isPressed = false
|
||||||
|
if let handler { RemoveEventHandler(handler) }
|
||||||
|
handler = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func disposeOnMainThread() {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
MainActor.assumeIsolated { dispose() }
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async { self.dispose() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func installHandler() throws {
|
||||||
|
guard handler == nil else { return }
|
||||||
|
var types = [
|
||||||
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
|
||||||
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)),
|
||||||
|
]
|
||||||
|
let status = InstallEventHandler(
|
||||||
|
GetApplicationEventTarget(), { _, event, context in
|
||||||
|
guard let event, let context else { return OSStatus(eventNotHandledErr) }
|
||||||
|
return MainActor.assumeIsolated {
|
||||||
|
Unmanaged<NativeHotKey>.fromOpaque(context).takeUnretainedValue().receive(event)
|
||||||
|
}
|
||||||
|
}, types.count, &types, Unmanaged.passUnretained(self).toOpaque(), &handler
|
||||||
|
)
|
||||||
|
guard status == noErr else {
|
||||||
|
throw ShortcutError.native(operation: "install the activation shortcut handler", status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func receive(_ event: EventRef) -> OSStatus {
|
||||||
|
precondition(Thread.isMainThread)
|
||||||
|
var keyID = EventHotKeyID()
|
||||||
|
let status = GetEventParameter(
|
||||||
|
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
|
||||||
|
nil, MemoryLayout<EventHotKeyID>.size, nil, &keyID
|
||||||
|
)
|
||||||
|
guard status == noErr, keyID.signature == Self.signature,
|
||||||
|
hotKey != nil, keyID.id == identifier else { return OSStatus(eventNotHandledErr) }
|
||||||
|
switch GetEventKind(event) {
|
||||||
|
case UInt32(kEventHotKeyPressed):
|
||||||
|
guard !isPressed else { return noErr }
|
||||||
|
isPressed = true
|
||||||
|
owner?.activateFromEvent()
|
||||||
|
case UInt32(kEventHotKeyReleased):
|
||||||
|
isPressed = false
|
||||||
|
default:
|
||||||
|
return OSStatus(eventNotHandledErr)
|
||||||
|
}
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import AppKit
|
||||||
|
import Carbon
|
||||||
|
|
||||||
|
public struct KeyboardShortcut: Equatable, Sendable {
|
||||||
|
public let keyCode: UInt16
|
||||||
|
public let modifiers: NSEvent.ModifierFlags
|
||||||
|
|
||||||
|
public init?(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) {
|
||||||
|
let normalized = modifiers.intersection([.command, .option, .control, .shift])
|
||||||
|
guard !normalized.intersection([.command, .option, .control]).isEmpty,
|
||||||
|
Self.supports(keyCode) else { return nil }
|
||||||
|
self.keyCode = keyCode
|
||||||
|
self.modifiers = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let commandSpace = KeyboardShortcut(keyCode: 49, modifiers: .command)!
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public var displayName: String {
|
||||||
|
var result = ""
|
||||||
|
if modifiers.contains(.control) { result += "⌃" }
|
||||||
|
if modifiers.contains(.option) { result += "⌥" }
|
||||||
|
if modifiers.contains(.shift) { result += "⇧" }
|
||||||
|
if modifiers.contains(.command) { result += "⌘" }
|
||||||
|
return result + keyName
|
||||||
|
}
|
||||||
|
|
||||||
|
var carbonModifiers: UInt32 {
|
||||||
|
var result: UInt32 = 0
|
||||||
|
if modifiers.contains(.command) { result |= UInt32(cmdKey) }
|
||||||
|
if modifiers.contains(.option) { result |= UInt32(optionKey) }
|
||||||
|
if modifiers.contains(.control) { result |= UInt32(controlKey) }
|
||||||
|
if modifiers.contains(.shift) { result |= UInt32(shiftKey) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedValue: [String: Int] {
|
||||||
|
["keyCode": Int(keyCode), "modifiers": Int(modifiers.rawValue)]
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(storedValue: Any?) {
|
||||||
|
guard let record = storedValue as? [String: Any],
|
||||||
|
let code = Self.storedInteger(record["keyCode"]),
|
||||||
|
let flags = Self.storedInteger(record["modifiers"]),
|
||||||
|
let keyCode = UInt16(exactly: code), flags >= 0,
|
||||||
|
let shortcut = Self(keyCode: keyCode, modifiers: .init(rawValue: UInt(flags))),
|
||||||
|
shortcut.modifiers.rawValue == UInt(flags) else { return nil }
|
||||||
|
self = shortcut
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func storedInteger(_ value: Any?) -> Int? {
|
||||||
|
guard let number = value as? NSNumber,
|
||||||
|
CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
|
||||||
|
return Int(exactly: number.doubleValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func supports(_ keyCode: UInt16) -> Bool {
|
||||||
|
switch keyCode {
|
||||||
|
case 0...51, 53, 64, 65, 67, 69, 71, 75, 76, 78...88, 90...107, 109...111, 113...126:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private var keyName: String {
|
||||||
|
switch keyCode {
|
||||||
|
case 36: return "Return"
|
||||||
|
case 48: return "Tab"
|
||||||
|
case 49: return "Space"
|
||||||
|
case 51: return "Delete"
|
||||||
|
case 53: return "Escape"
|
||||||
|
case 64: return "F17"
|
||||||
|
case 71: return "Clear"
|
||||||
|
case 76: return "Enter"
|
||||||
|
case 79: return "F18"
|
||||||
|
case 80: return "F19"
|
||||||
|
case 90: return "F20"
|
||||||
|
case 96: return "F5"
|
||||||
|
case 97: return "F6"
|
||||||
|
case 98: return "F7"
|
||||||
|
case 99: return "F3"
|
||||||
|
case 100: return "F8"
|
||||||
|
case 101: return "F9"
|
||||||
|
case 102: return "Eisu"
|
||||||
|
case 103: return "F11"
|
||||||
|
case 104: return "Kana"
|
||||||
|
case 105: return "F13"
|
||||||
|
case 106: return "F16"
|
||||||
|
case 107: return "F14"
|
||||||
|
case 109: return "F10"
|
||||||
|
case 110: return "Menu"
|
||||||
|
case 111: return "F12"
|
||||||
|
case 113: return "F15"
|
||||||
|
case 114: return "Help"
|
||||||
|
case 115: return "Home"
|
||||||
|
case 116: return "Page Up"
|
||||||
|
case 117: return "Forward Delete"
|
||||||
|
case 118: return "F4"
|
||||||
|
case 119: return "End"
|
||||||
|
case 120: return "F2"
|
||||||
|
case 121: return "Page Down"
|
||||||
|
case 122: return "F1"
|
||||||
|
case 123: return "←"
|
||||||
|
case 124: return "→"
|
||||||
|
case 125: return "↓"
|
||||||
|
case 126: return "↑"
|
||||||
|
default: return translatedKeyName ?? "Key \(keyCode)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private var translatedKeyName: String? {
|
||||||
|
guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(),
|
||||||
|
let property = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let data = Unmanaged<CFData>.fromOpaque(property).takeUnretainedValue()
|
||||||
|
guard let bytes = CFDataGetBytePtr(data) else { return nil }
|
||||||
|
let layout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
|
||||||
|
var deadKeyState: UInt32 = 0
|
||||||
|
var length = 0
|
||||||
|
var characters = [UniChar](repeating: 0, count: 8)
|
||||||
|
let status = UCKeyTranslate(
|
||||||
|
layout, keyCode, UInt16(kUCKeyActionDisplay), 0, UInt32(LMGetKbdType()),
|
||||||
|
UInt32(kUCKeyTranslateNoDeadKeysMask), &deadKeyState,
|
||||||
|
characters.count, &length, &characters
|
||||||
|
)
|
||||||
|
guard status == noErr, length > 0 else { return nil }
|
||||||
|
return String(utf16CodeUnits: characters, count: length).uppercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func shortcutRequiresAnActivationModifierAndNormalizesEventFlags() {
|
||||||
|
#expect(KeyboardShortcut(keyCode: 0, modifiers: []) == nil)
|
||||||
|
#expect(KeyboardShortcut(keyCode: 0, modifiers: .shift) == nil)
|
||||||
|
#expect(KeyboardShortcut(keyCode: 0, modifiers: [.shift, .function, .capsLock]) == nil)
|
||||||
|
let activationModifiers: [NSEvent.ModifierFlags] = [.command, .option, .control]
|
||||||
|
for modifier in activationModifiers {
|
||||||
|
let shortcut = KeyboardShortcut(
|
||||||
|
keyCode: 0, modifiers: [modifier, .shift, .capsLock, .function, .numericPad]
|
||||||
|
)
|
||||||
|
#expect(shortcut?.modifiers == [modifier, .shift])
|
||||||
|
#expect(shortcut?.keyCode == 0)
|
||||||
|
}
|
||||||
|
#expect(KeyboardShortcut.commandSpace == KeyboardShortcut(keyCode: 49, modifiers: .command))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func shortcutRejectsModifierOnlyMediaAndUndefinedKeyCodes() {
|
||||||
|
let rejected: [UInt16] = [52, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 66, 68, 70, 72, 73, 74, 77, 89, 108, 112, 127, .max]
|
||||||
|
for code in rejected {
|
||||||
|
#expect(KeyboardShortcut(keyCode: code, modifiers: .command) == nil)
|
||||||
|
}
|
||||||
|
let accepted: [UInt16] = [0, 10, 36, 49, 53, 64, 65, 67, 69, 71, 75, 76, 78, 88, 90, 95, 96, 102, 104, 107, 110, 126]
|
||||||
|
for code in accepted {
|
||||||
|
#expect(KeyboardShortcut(keyCode: code, modifiers: [.control, .option]) != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user