264 lines
10 KiB
Swift
264 lines
10 KiB
Swift
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
|
|
}
|
|
}
|