diff --git a/Sources/KeyboardShortcuts/RecorderCocoa.swift b/Sources/KeyboardShortcuts/RecorderCocoa.swift index 79353390..11927eff 100644 --- a/Sources/KeyboardShortcuts/RecorderCocoa.swift +++ b/Sources/KeyboardShortcuts/RecorderCocoa.swift @@ -199,6 +199,8 @@ extension KeyboardShortcuts { preventBecomingKey() } + private var existingNSMenuForUs: NSMenuItem? + /// :nodoc: override public func becomeFirstResponder() -> Bool { let shouldBecomeFirstResponder = super.becomeFirstResponder() @@ -211,6 +213,21 @@ extension KeyboardShortcuts { showsCancelButton = !stringValue.isEmpty hideCaret() KeyboardShortcuts.isPaused = true // The position here matters. + + if let existingShortcut = getShortcut(for: shortcutName) { + // Assuming we have a consistent NSMenuItem state, find the NSMenuItem instance that represents us right now + // This works only if SwiftUI has updated the NSMenuItem. It seems (macOS 15) that even if the shortcut state is updated on KeyboardShortcutView, that the actual NSMenuItem isn't refreshed + // This means that if we change it multiple times in a row, this lookup returns nil. + // What seems to work is just hanging onto the last found reference... + if let foundMenu = existingShortcut.takenByMainMenu { + existingNSMenuForUs = foundMenu + } + } +// print("existingNSMenuForUs: \(existingNSMenuForUs)") + + // Unregsiter the shortcut by this name, so that when we search the menus, we don't find ourselves and think that it's already taken + // This doesn't always work for SwiftUI generated NSMenuItems (from .keyboardShortcut()) - so we also use existingNSMenuForUs, above + KeyboardShortcuts.userDefaultsDisable(name: shortcutName) eventMonitor = LocalEventMonitor(events: [.keyDown, .leftMouseUp, .rightMouseUp]) { [weak self] event in guard let self else { @@ -270,18 +287,22 @@ extension KeyboardShortcuts { return nil } - if let menuItem = shortcut.takenByMainMenu { - // TODO: Find a better way to make it possible to dismiss the alert by pressing "Enter". How can we make the input automatically temporarily lose focus while the alert is open? - self.blur() - - NSAlert.showModal( - for: self.window, - title: String.localizedStringWithFormat("keyboard_shortcut_used_by_menu_item".localized, menuItem.title) - ) - - self.focus() - return nil + if let menuItem = shortcut.takenByMainMenu { + let isOurOwnMenuInstance = existingNSMenuForUs != nil ? existingNSMenuForUs === menuItem : false + if !isOurOwnMenuInstance { + // TODO: Find a better way to make it possible to dismiss the alert by pressing "Enter". How can we make the input automatically temporarily lose focus while the alert is open? + self.blur() + + NSAlert.showModal( + for: self.window, + title: String.localizedStringWithFormat("keyboard_shortcut_used_by_menu_item".localized, menuItem.title) + ) + + self.focus() + + return nil + } } if shortcut.isTakenBySystem { @@ -311,7 +332,6 @@ extension KeyboardShortcuts { self.saveShortcut(shortcut) self.blur() - return nil }.start() diff --git a/Sources/KeyboardShortcuts/Shortcut.swift b/Sources/KeyboardShortcuts/Shortcut.swift index 26cbbee7..da91d5b4 100644 --- a/Sources/KeyboardShortcuts/Shortcut.swift +++ b/Sources/KeyboardShortcuts/Shortcut.swift @@ -244,7 +244,10 @@ private let keyToKeyEquivalentString: [KeyboardShortcuts.Key: String] = [ extension KeyboardShortcuts.Shortcut { @MainActor // `TISGetInputSourceProperty` crashes if called on a non-main thread. - fileprivate func keyToCharacter() -> String? { + /** + This returns a String represntation of the Key. + */ + public func keyToCharacter() -> String? { // Some characters cannot be automatically translated. if let key, diff --git a/Sources/KeyboardShortcuts/SwiftUI+Extensions.swift b/Sources/KeyboardShortcuts/SwiftUI+Extensions.swift new file mode 100644 index 00000000..b12d7e6c --- /dev/null +++ b/Sources/KeyboardShortcuts/SwiftUI+Extensions.swift @@ -0,0 +1,108 @@ +// Created by Neil Clayton on 17/07/2024. +// Using ideas by mbenoukaiss, https://github.com/sindresorhus/KeyboardShortcuts/issues/101 + + +import Foundation +import SwiftUI +import Carbon + +// Provides a SwiftUI like wrapper function, that feels the same as the normal SwiftUI .keyboardShortcut view extension +@available(macOS 12.3, *) +extension View { + @MainActor + public func keyboardShortcut(_ shortcutName: KeyboardShortcuts.Name) -> some View { + KeyboardShortcutView(shortcutName: shortcutName) { + self + } + } +} + +@available(macOS 11.0, *) +extension KeyboardShortcuts.Shortcut { + @MainActor + var swiftKeyboardShortcut: KeyboardShortcut? { + if let keyEquivalent = toKeyEquivalent() { + return KeyboardShortcut(keyEquivalent, modifiers: toEventModifiers) + } + return nil + } +} + +// Holds the state of the shortcut, and changes that state when the shortcut changes +// This causes the related NSMenuItem to also update (yipeee) +@available(macOS 12.3, *) +@MainActor +struct KeyboardShortcutView: View { + @State private var shortcutName: KeyboardShortcuts.Name + @State private var shortcut: KeyboardShortcuts.Shortcut? + + private var content: () -> Content + + init(shortcutName: KeyboardShortcuts.Name, content: @escaping () -> Content) { + self.shortcutName = shortcutName + self.shortcut = KeyboardShortcuts.getShortcut(for: shortcutName) + self.content = content + } + + var shortcutBody: some View { + content() + .keyboardShortcut(shortcut?.swiftKeyboardShortcut) + } + + var body: some View { + shortcutBody + // Called only when the shortcut is updated + .onReceive(NotificationCenter.default.publisher(for: .shortcutByNameDidChange)) { notification in + guard let name = notification.userInfo?["name"] as? KeyboardShortcuts.Name, name == shortcutName else { + return + } + let current = KeyboardShortcuts.getShortcut(for: name) + // this updates the shortcut state locally, refreshing the View, thus updating the SwiftUI menu item + // It's also fine if it is nil (which happens when you set the shortcut, in RecorderCocoa). + // See the comment on becomeFirstResponder (in short: so that you can reassign the SAME keypress to a shortcut, without it whining that it's already in use) + shortcut = current + } + } +} + +@available(macOS 11.0, *) +extension KeyboardShortcuts.Shortcut { + @MainActor + func toKeyEquivalent() -> KeyEquivalent? { + guard let keyCharacter = keyToCharacter() else { + return nil + } + return KeyEquivalent(Character(keyCharacter)) + } + + var toEventModifiers: SwiftUI.EventModifiers { + var modifiers: SwiftUI.EventModifiers = [] + + if self.modifiers.contains(NSEvent.ModifierFlags.command) { + modifiers.insert(EventModifiers.command) + } + + if self.modifiers.contains(NSEvent.ModifierFlags.control) { + modifiers.insert(EventModifiers.control) + } + + if self.modifiers.contains(NSEvent.ModifierFlags.option) { + modifiers.insert(EventModifiers.option) + } + + if self.modifiers.contains(NSEvent.ModifierFlags.shift) { + modifiers.insert(EventModifiers.shift) + } + + if self.modifiers.contains(NSEvent.ModifierFlags.capsLock) { + modifiers.insert(EventModifiers.capsLock) + } + + if self.modifiers.contains(NSEvent.ModifierFlags.numericPad) { + modifiers.insert(EventModifiers.numericPad) + } + + return modifiers + } + +}