Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for changeable SwiftUI .keyboardShortcuts, based on the… #181

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
77 changes: 49 additions & 28 deletions Sources/KeyboardShortcuts/RecorderCocoa.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@
preventBecomingKey()
}

private var existingNSMenuForUs: NSMenuItem? = nil

Check warning on line 202 in Sources/KeyboardShortcuts/RecorderCocoa.swift

View workflow job for this annotation

GitHub Actions / lint

Redundant Optional Initialization Violation: Initializing an optional variable with nil is redundant (redundant_optional_initialization)

Check warning on line 203 in Sources/KeyboardShortcuts/RecorderCocoa.swift

View workflow job for this annotation

GitHub Actions / lint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
/// :nodoc:
override public func becomeFirstResponder() -> Bool {
let shouldBecomeFirstResponder = super.becomeFirstResponder()
Expand All @@ -211,55 +213,70 @@
showsCancelButton = !stringValue.isEmpty
hideCaret()
KeyboardShortcuts.isPaused = true // The position here matters.

if let existingShortcut = getShortcut(for: shortcutName) {
// Assuming we have a consisten 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)")

eventMonitor = LocalEventMonitor(events: [.keyDown, .leftMouseUp, .rightMouseUp]) { [weak self] event in
// 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)

self.eventMonitor = LocalEventMonitor(events: [.keyDown, .leftMouseUp, .rightMouseUp]) { [weak self] event in
scornflake marked this conversation as resolved.
Show resolved Hide resolved
guard let self else {
return nil
}

let clickPoint = self.convert(event.locationInWindow, from: nil)
let clickMargin = 3.0

if
event.type == .leftMouseUp || event.type == .rightMouseUp,
!self.bounds.insetBy(dx: -clickMargin, dy: -clickMargin).contains(clickPoint)
{

Check warning on line 243 in Sources/KeyboardShortcuts/RecorderCocoa.swift

View workflow job for this annotation

GitHub Actions / lint

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration (opening_brace)
self.blur()
return event
}

guard event.isKeyEvent else {
return nil
}

if
event.modifiers.isEmpty,
event.specialKey == .tab
{

Check warning on line 255 in Sources/KeyboardShortcuts/RecorderCocoa.swift

View workflow job for this annotation

GitHub Actions / lint

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration (opening_brace)
self.blur()

// We intentionally bubble up the event so it can focus the next responder.
return event
}

if
event.modifiers.isEmpty,
event.keyCode == kVK_Escape // TODO: Make this strongly typed.
{

Check warning on line 265 in Sources/KeyboardShortcuts/RecorderCocoa.swift

View workflow job for this annotation

GitHub Actions / lint

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration (opening_brace)
self.blur()
return nil
}

if
event.modifiers.isEmpty,
event.specialKey == .delete
|| event.specialKey == .deleteForward
|| event.specialKey == .backspace
{

Check warning on line 275 in Sources/KeyboardShortcuts/RecorderCocoa.swift

View workflow job for this annotation

GitHub Actions / lint

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration (opening_brace)
self.clear()
return nil
}

// The “shift” key is not allowed without other modifiers or a function key, since it doesn't actually work.
guard
!event.modifiers.subtracting(.shift).isEmpty
Expand All @@ -269,24 +286,28 @@
NSSound.beep()
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
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 {
self.blur()

let modalResponse = NSAlert.showModal(
for: self.window,
title: "keyboard_shortcut_used_by_system".localized,
Expand All @@ -297,21 +318,21 @@
"force_use_shortcut".localized
]
)

self.focus()

// If the user has selected "Use Anyway" in the dialog (the second option), we'll continue setting the keyboard shorcut even though it's reserved by the system.
guard modalResponse == .alertSecondButtonReturn else {
return nil
}
}

self.stringValue = "\(shortcut)"
self.showsCancelButton = true

self.saveShortcut(shortcut)
self.blur()

scornflake marked this conversation as resolved.
Show resolved Hide resolved
sindresorhus marked this conversation as resolved.
Show resolved Hide resolved
return nil
}.start()

Expand Down
123 changes: 123 additions & 0 deletions Sources/KeyboardShortcuts/SwiftUI+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Created by Neil Clayton on 17/07/2024.
// Using ideas by mbenoukaiss, https://github.com/sindresorhus/KeyboardShortcuts/issues/101


import Foundation
import SwiftUI
import KeyboardShortcuts
import Carbon

// Provides a SwiftUI like wrapper func, that feels the same as the normal SwiftUI keyboardShortcut
@available(macOS 11.0, *)
extension View {
@ViewBuilder
scornflake marked this conversation as resolved.
Show resolved Hide resolved
public func keyboardShortcut(_ shortcutName: KeyboardShortcuts.Name) -> some View {
KeyboardShortcutView(shortcutName: shortcutName) {
self
}
}
}

// 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 11.0, *)
struct KeyboardShortcutView<Content: View>: View {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the idiomatic way to do this would be to use ViewModifier instead

Copy link
Author

@scornflake scornflake Jul 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup - there's a view modifier directly above (which uses KeyboardShortcutView). I'm only using KeyboardShortcutView as a wrapper to store state so that the view/menu changes when the shortcut changes. If this is possible using a viewModifier function, happy to learn about it and adjust.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that's a View extension, not a ViewModifier.

@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
}

@ViewBuilder
var shortcutBody: some View {
if let shortcut, let keyEquivalent = shortcut.toKeyEquivalent() {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to do if and ruin the view id when it changes. Instead, use https://developer.apple.com/documentation/financekitui/addordertowalletbutton/keyboardshortcut(_:)-883ia#

content()
.keyboardShortcut(keyEquivalent, modifiers: shortcut.toEventModifiers())
scornflake marked this conversation as resolved.
Show resolved Hide resolved
} else {
content()
}
}

var body: some View {
shortcutBody
// Called only when the shortcut is updated
.onReceive(NotificationCenter.default.publisher(for: .shortcutByNameDidChange)) { notification in
if let name = notification.userInfo?["name"] as? KeyboardShortcuts.Name, name == shortcutName {
scornflake marked this conversation as resolved.
Show resolved Hide resolved
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)
print("Shortcut \(shortcutName) updated to: \(current?.description ?? "nil")")
shortcut = current
}
}
}
}

@available(macOS 11.0, *)
extension KeyboardShortcuts.Shortcut {
func toKeyEquivalent() -> KeyEquivalent? {
scornflake marked this conversation as resolved.
Show resolved Hide resolved
let carbonKeyCode = UInt16(self.carbonKeyCode)
let maxNameLength = 4
var nameBuffer = [UniChar](repeating: 0, count: maxNameLength)
var nameLength = 0

let modifierKeys = UInt32(alphaLock >> 8) & 0xFF // Caps Lock
var deadKeys: UInt32 = 0
let keyboardType = UInt32(LMGetKbdType())

let source = TISCopyCurrentKeyboardLayoutInputSource().takeRetainedValue()
guard let ptr = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
NSLog("Could not get keyboard layout data")
return nil
}
let layoutData = Unmanaged<CFData>.fromOpaque(ptr).takeUnretainedValue() as Data
let osStatus = layoutData.withUnsafeBytes {
UCKeyTranslate($0.bindMemory(to: UCKeyboardLayout.self).baseAddress, carbonKeyCode, UInt16(kUCKeyActionDown),
modifierKeys, keyboardType, UInt32(kUCKeyTranslateNoDeadKeysMask),
&deadKeys, maxNameLength, &nameLength, &nameBuffer)
}
guard osStatus == noErr else {
NSLog("Code: 0x%04X Status: %+i", carbonKeyCode, osStatus);
return nil
}

return KeyEquivalent(Character(String(utf16CodeUnits: nameBuffer, count: nameLength)))
scornflake marked this conversation as resolved.
Show resolved Hide resolved
}

func toEventModifiers() -> SwiftUI.EventModifiers {
scornflake marked this conversation as resolved.
Show resolved Hide resolved
var modifiers: SwiftUI.EventModifiers = []

if self.modifiers.contains(NSEvent.ModifierFlags.command) {
modifiers.update(with: EventModifiers.command)
scornflake marked this conversation as resolved.
Show resolved Hide resolved
}

if self.modifiers.contains(NSEvent.ModifierFlags.control) {
modifiers.update(with: EventModifiers.control)
}

if self.modifiers.contains(NSEvent.ModifierFlags.option) {
modifiers.update(with: EventModifiers.option)
}

if self.modifiers.contains(NSEvent.ModifierFlags.shift) {
modifiers.update(with: EventModifiers.shift)
}

if self.modifiers.contains(NSEvent.ModifierFlags.capsLock) {
modifiers.update(with: EventModifiers.capsLock)
}

if self.modifiers.contains(NSEvent.ModifierFlags.numericPad) {
modifiers.update(with: EventModifiers.numericPad)
}

return modifiers
}

}
Loading