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
44 changes: 32 additions & 12 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?

/// :nodoc:
override public func becomeFirstResponder() -> Bool {
let shouldBecomeFirstResponder = super.becomeFirstResponder()
Expand All @@ -211,6 +213,21 @@
showsCancelButton = !stringValue.isEmpty
hideCaret()
KeyboardShortcuts.isPaused = true // The position here matters.

Check warning on line 216 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)
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 {
Expand All @@ -223,7 +240,7 @@
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
}
Expand All @@ -235,7 +252,7 @@
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.
Expand All @@ -245,7 +262,7 @@
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
}
Expand All @@ -255,7 +272,7 @@
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
}
Expand All @@ -270,18 +287,22 @@
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 {
Expand Down Expand Up @@ -311,7 +332,6 @@

self.saveShortcut(shortcut)
self.blur()

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

Expand Down
5 changes: 4 additions & 1 deletion Sources/KeyboardShortcuts/Shortcut.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,14 @@
if
keyToCharacter() == keyEquivalent,
modifiers == keyEquivalentModifierMask
{

Check warning on line 128 in Sources/KeyboardShortcuts/Shortcut.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)
return item
}

if
let submenu = item.submenu,
let menuItem = menuItemWithMatchingShortcut(in: submenu)
{

Check warning on line 135 in Sources/KeyboardShortcuts/Shortcut.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)
return menuItem
}
}
Expand Down Expand Up @@ -244,12 +244,15 @@

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,
let character = keyToCharacterMapping[key]
{

Check warning on line 255 in Sources/KeyboardShortcuts/Shortcut.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)
return character
}

Expand Down
108 changes: 108 additions & 0 deletions Sources/KeyboardShortcuts/SwiftUI+Extensions.swift
Original file line number Diff line number Diff line change
@@ -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() {
scornflake marked this conversation as resolved.
Show resolved Hide resolved
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<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
}

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? {
scornflake marked this conversation as resolved.
Show resolved Hide resolved
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
}

Check warning on line 107 in Sources/KeyboardShortcuts/SwiftUI+Extensions.swift

View workflow job for this annotation

GitHub Actions / lint

Vertical Whitespace before Closing Braces Violation: Don't include vertical whitespace (empty line) before closing braces (vertical_whitespace_closing_braces)
}
Loading