KeyboardShortcuts/Sources/KeyboardShortcuts/KeyboardShortcuts.swift

590 lines
14 KiB
Swift

import Foundation
/**
Global keyboard shortcuts for your macOS app.
*/
public enum KeyboardShortcuts {
private static var registeredShortcuts = Set<Shortcut>()
private static var legacyKeyDownHandlers = [Name: [() -> Void]]()
private static var legacyKeyUpHandlers = [Name: [() -> Void]]()
private static var streamKeyDownHandlers = [Name: [UUID: () -> Void]]()
private static var streamKeyUpHandlers = [Name: [UUID: () -> Void]]()
private static var shortcutsForLegacyHandlers: Set<Shortcut> {
let shortcuts = [legacyKeyDownHandlers.keys, legacyKeyUpHandlers.keys]
.flatMap { $0 }
.compactMap(\.shortcut)
return Set(shortcuts)
}
private static var shortcutsForStreamHandlers: Set<Shortcut> {
let shortcuts = [streamKeyDownHandlers.keys, streamKeyUpHandlers.keys]
.flatMap { $0 }
.compactMap(\.shortcut)
return Set(shortcuts)
}
private static var shortcutsForHandlers: Set<Shortcut> {
shortcutsForLegacyHandlers.union(shortcutsForStreamHandlers)
}
/**
When `true`, event handlers will not be called for registered keyboard shortcuts.
*/
static var isPaused = false
/**
Enable/disable monitoring of all keyboard shortcuts.
The default is `true`.
*/
public static var isEnabled = true {
didSet {
guard isEnabled != oldValue else {
return
}
CarbonKeyboardShortcuts.updateEventHandler()
}
}
/**
Enable keyboard shortcuts to work even when an `NSMenu` is open by setting this property when the menu opens and closes.
`NSMenu` runs in a tracking run mode that blocks keyboard shortcuts events. When you set this property to `true`, it switches to a different kind of event handler, which does work when the menu is open.
The main use-case for this is toggling the menu of a menu bar app with a keyboard shortcut.
```swift
import Cocoa
import KeyboardShortcuts
let menu = NSMenu()
let menuDelegate = MenuDelegate()
menu.delegate = menuDelegate
final class MenuDelegate: NSObject, NSMenuDelegate {
func menuWillOpen(_ menu: NSMenu) {
KeyboardShortcuts.isMenuOpen = true
}
func menuDidClose(_ menu: NSMenu) {
KeyboardShortcuts.isMenuOpen = false
}
}
```
*/
public static var isMenuOpen = false {
didSet {
guard isMenuOpen != oldValue else {
return
}
CarbonKeyboardShortcuts.updateEventHandler()
}
}
private static func register(_ shortcut: Shortcut) {
guard !registeredShortcuts.contains(shortcut) else {
return
}
CarbonKeyboardShortcuts.register(
shortcut,
onKeyDown: handleOnKeyDown,
onKeyUp: handleOnKeyUp
)
registeredShortcuts.insert(shortcut)
}
/**
Register the shortcut for the given name if it has a shortcut.
*/
private static func registerShortcutIfNeeded(for name: Name) {
guard let shortcut = getShortcut(for: name) else {
return
}
register(shortcut)
}
private static func unregister(_ shortcut: Shortcut) {
CarbonKeyboardShortcuts.unregister(shortcut)
registeredShortcuts.remove(shortcut)
}
/**
Unregister the given shortcut if it has no handlers.
*/
private static func unregisterIfNeeded(_ shortcut: Shortcut) {
guard !shortcutsForHandlers.contains(shortcut) else {
return
}
unregister(shortcut)
}
/**
Unregister the shortcut for the given name if it has no handlers.
*/
private static func unregisterShortcutIfNeeded(for name: Name) {
guard let shortcut = name.shortcut else {
return
}
unregisterIfNeeded(shortcut)
}
private static func unregisterAll() {
CarbonKeyboardShortcuts.unregisterAll()
registeredShortcuts.removeAll()
// TODO: Should remove user defaults too.
}
/**
Remove all handlers receiving keyboard shortcuts events.
This can be used to reset the handlers before re-creating them to avoid having multiple handlers for the same shortcut.
- Note: This method does not affect listeners using `.on()`.
*/
public static func removeAllHandlers() {
let shortcutsToUnregister = shortcutsForLegacyHandlers.subtracting(shortcutsForStreamHandlers)
for shortcut in shortcutsToUnregister {
unregister(shortcut)
}
legacyKeyDownHandlers = [:]
legacyKeyUpHandlers = [:]
}
// TODO: Also add `.isEnabled(_ name: Name)`.
/**
Disable the keyboard shortcut for one or more names.
*/
public static func disable(_ names: [Name]) {
for name in names {
guard let shortcut = getShortcut(for: name) else {
continue
}
unregister(shortcut)
}
}
/**
Disable the keyboard shortcut for one or more names.
*/
public static func disable(_ names: Name...) {
disable(names)
}
/**
Enable the keyboard shortcut for one or more names.
*/
public static func enable(_ names: [Name]) {
for name in names {
guard let shortcut = getShortcut(for: name) else {
continue
}
register(shortcut)
}
}
/**
Enable the keyboard shortcut for one or more names.
*/
public static func enable(_ names: Name...) {
enable(names)
}
/**
Reset the keyboard shortcut for one or more names.
If the `Name` has a default shortcut, it will reset to that.
- Note: This overload exists as Swift doesn't support splatting.
```swift
import SwiftUI
import KeyboardShortcuts
struct SettingsScreen: View {
var body: some View {
VStack {
//
Button("Reset All") {
KeyboardShortcuts.reset(
.toggleUnicornMode,
.showRainbow
)
}
}
}
}
```
*/
public static func reset(_ names: [Name]) {
for name in names {
setShortcut(name.defaultShortcut, for: name)
}
}
/**
Reset the keyboard shortcut for one or more names.
If the `Name` has a default shortcut, it will reset to that.
```swift
import SwiftUI
import KeyboardShortcuts
struct SettingsScreen: View {
var body: some View {
VStack {
//
Button("Reset All") {
KeyboardShortcuts.reset(
.toggleUnicornMode,
.showRainbow
)
}
}
}
}
```
*/
public static func reset(_ names: Name...) {
reset(names)
}
/**
Set the keyboard shortcut for a name.
Setting it to `nil` removes the shortcut, even if the `Name` has a default shortcut defined. Use `.reset()` if you want it to respect the default shortcut.
You would usually not need this as the user would be the one setting the shortcut in a settings user-interface, but it can be useful when, for example, migrating from a different keyboard shortcuts package.
*/
public static func setShortcut(_ shortcut: Shortcut?, for name: Name) {
if let shortcut {
userDefaultsSet(name: name, shortcut: shortcut)
} else {
if name.defaultShortcut != nil {
userDefaultsDisable(name: name)
} else {
userDefaultsRemove(name: name)
}
}
}
/**
Get the keyboard shortcut for a name.
*/
public static func getShortcut(for name: Name) -> Shortcut? {
guard
let data = UserDefaults.standard.string(forKey: userDefaultsKey(for: name))?.data(using: .utf8),
let decoded = try? JSONDecoder().decode(Shortcut.self, from: data)
else {
return nil
}
return decoded
}
private static func handleOnKeyDown(_ shortcut: Shortcut) {
guard !isPaused else {
return
}
for (name, handlers) in legacyKeyDownHandlers {
guard getShortcut(for: name) == shortcut else {
continue
}
for handler in handlers {
handler()
}
}
for (name, handlers) in streamKeyDownHandlers {
guard getShortcut(for: name) == shortcut else {
continue
}
for handler in handlers.values {
handler()
}
}
}
private static func handleOnKeyUp(_ shortcut: Shortcut) {
guard !isPaused else {
return
}
for (name, handlers) in legacyKeyUpHandlers {
guard getShortcut(for: name) == shortcut else {
continue
}
for handler in handlers {
handler()
}
}
for (name, handlers) in streamKeyUpHandlers {
guard getShortcut(for: name) == shortcut else {
continue
}
for handler in handlers.values {
handler()
}
}
}
/**
Listen to the keyboard shortcut with the given name being pressed.
You can register multiple listeners.
You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
```swift
import Cocoa
import KeyboardShortcuts
@main
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
KeyboardShortcuts.onKeyDown(for: .toggleUnicornMode) { [self] in
isUnicornMode.toggle()
}
}
}
```
*/
public static func onKeyDown(for name: Name, action: @escaping () -> Void) {
legacyKeyDownHandlers[name, default: []].append(action)
registerShortcutIfNeeded(for: name)
}
/**
Listen to the keyboard shortcut with the given name being pressed.
You can register multiple listeners.
You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
```swift
import Cocoa
import KeyboardShortcuts
@main
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
KeyboardShortcuts.onKeyUp(for: .toggleUnicornMode) { [self] in
isUnicornMode.toggle()
}
}
}
```
*/
public static func onKeyUp(for name: Name, action: @escaping () -> Void) {
legacyKeyUpHandlers[name, default: []].append(action)
registerShortcutIfNeeded(for: name)
}
private static let userDefaultsPrefix = "KeyboardShortcuts_"
private static func userDefaultsKey(for shortcutName: Name) -> String { "\(userDefaultsPrefix)\(shortcutName.rawValue)"
}
static func userDefaultsDidChange(name: Name) {
// TODO: Use proper UserDefaults observation instead of this.
NotificationCenter.default.post(name: .shortcutByNameDidChange, object: nil, userInfo: ["name": name])
}
static func userDefaultsSet(name: Name, shortcut: Shortcut) {
guard let encoded = try? JSONEncoder().encode(shortcut).toString else {
return
}
if let oldShortcut = getShortcut(for: name) {
unregister(oldShortcut)
}
register(shortcut)
UserDefaults.standard.set(encoded, forKey: userDefaultsKey(for: name))
userDefaultsDidChange(name: name)
}
static func userDefaultsDisable(name: Name) {
guard let shortcut = getShortcut(for: name) else {
return
}
UserDefaults.standard.set(false, forKey: userDefaultsKey(for: name))
unregister(shortcut)
userDefaultsDidChange(name: name)
}
static func userDefaultsRemove(name: Name) {
guard let shortcut = getShortcut(for: name) else {
return
}
UserDefaults.standard.removeObject(forKey: userDefaultsKey(for: name))
unregister(shortcut)
userDefaultsDidChange(name: name)
}
static func userDefaultsContains(name: Name) -> Bool {
UserDefaults.standard.object(forKey: userDefaultsKey(for: name)) != nil
}
}
extension KeyboardShortcuts {
@available(macOS 10.15, *)
public enum EventType {
case keyDown
case keyUp
}
/**
Listen to the keyboard shortcut with the given name being pressed.
You can register multiple listeners.
You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
Ending the async sequence will stop the listener. For example, in the below example, the listener will stop when the view disappears.
```swift
import SwiftUI
import KeyboardShortcuts
struct ContentView: View {
@State private var isUnicornMode = false
var body: some View {
Text(isUnicornMode ? "🦄" : "🐴")
.task {
for await event in KeyboardShortcuts.events(for: .toggleUnicornMode) where event == .keyUp {
isUnicornMode.toggle()
}
}
}
}
```
- Note: This method is not affected by `.removeAllHandlers()`.
*/
@available(macOS 10.15, *)
public static func events(for name: Name) -> AsyncStream<KeyboardShortcuts.EventType> {
AsyncStream { continuation in
let id = UUID()
DispatchQueue.main.async {
streamKeyDownHandlers[name, default: [:]][id] = {
continuation.yield(.keyDown)
}
streamKeyUpHandlers[name, default: [:]][id] = {
continuation.yield(.keyUp)
}
registerShortcutIfNeeded(for: name)
}
continuation.onTermination = { _ in
DispatchQueue.main.async {
streamKeyDownHandlers[name]?[id] = nil
streamKeyUpHandlers[name]?[id] = nil
unregisterShortcutIfNeeded(for: name)
}
}
}
}
/**
Listen to keyboard shortcut events with the given name and type.
You can register multiple listeners.
You can safely call this even if the user has not yet set a keyboard shortcut. It will just be inactive until they do.
Ending the async sequence will stop the listener. For example, in the below example, the listener will stop when the view disappears.
```swift
import SwiftUI
import KeyboardShortcuts
struct ContentView: View {
@State private var isUnicornMode = false
var body: some View {
Text(isUnicornMode ? "🦄" : "🐴")
.task {
for await event in KeyboardShortcuts.events(for: .toggleUnicornMode) where event == .keyUp {
isUnicornMode.toggle()
}
}
}
}
```
- Note: This method is not affected by `.removeAllHandlers()`.
*/
@available(macOS 10.15, *)
public static func events(_ type: EventType, for name: Name) -> AsyncFilterSequence<AsyncStream<EventType>> {
events(for: name).filter { $0 == type }
}
@available(macOS 10.15, *)
@available(*, deprecated, renamed: "events(_:for:)")
public static func on(_ type: EventType, for name: Name) -> AsyncStream<Void> {
AsyncStream { continuation in
let id = UUID()
switch type {
case .keyDown:
streamKeyDownHandlers[name, default: [:]][id] = {
continuation.yield()
}
case .keyUp:
streamKeyUpHandlers[name, default: [:]][id] = {
continuation.yield()
}
}
registerShortcutIfNeeded(for: name)
continuation.onTermination = { _ in
switch type {
case .keyDown:
streamKeyDownHandlers[name]?[id] = nil
case .keyUp:
streamKeyUpHandlers[name]?[id] = nil
}
unregisterShortcutIfNeeded(for: name)
}
}
}
}
extension Notification.Name {
static let shortcutByNameDidChange = Self("KeyboardShortcuts_shortcutByNameDidChange")
}