Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions Example/KeyboardShortcutsExample/MainScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ extension KeyboardShortcuts.Name {
static let testShortcut4 = Self("testShortcut4")
}

private struct MultiChordRecorder: View {
@State private var sequence: ShortcutSequence = .init([])

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Multi-chord Recorder")
.bold()
Text("Recorded:")
Text(sequence.presentableDescription.isEmpty ? "-" : sequence.presentableDescription)
.font(.system(.body, design: .monospaced))
.foregroundStyle(.secondary)
ShortcutRecordView(
sequence: $sequence,
option: .init(enableSequences: true, maxSequenceLength: 2)
)
}
.frame(maxWidth: 300)
.padding()
}
}

private struct DynamicShortcutRecorder: View {
@FocusState private var isFocused: Bool

Expand Down Expand Up @@ -123,14 +144,16 @@ private struct DoubleShortcut: View {
}

struct MainScreen: View {
var body: some View {
VStack {
DoubleShortcut()
Divider()
DynamicShortcut()
}
.frame(width: 400, height: 320)
}
var body: some View {
VStack {
DoubleShortcut()
Divider()
DynamicShortcut()
Divider()
MultiChordRecorder()
}
.frame(width: 400, height: 400)
}
}

#Preview {
Expand Down
190 changes: 144 additions & 46 deletions Sources/KeyboardShortcuts/RecorderCocoa.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,33 @@
import Carbon.HIToolbox

extension KeyboardShortcuts {
public struct RecorderOption {
// Allow recording only the `Shift` modifier key like `shift+a`
// The “shift” key is not allowed without other modifiers or a function key after macOS 14
public let allowOnlyShiftModifier: Bool

// Check if the keyboard shortcut is already taken by the app's main menu
public let checkMenuCollision: Bool

// Allow recording a sequence of shortcuts
public let enableSequences: Bool

// The maximum number of shortcuts in a sequence.
public let maxSequenceLength: Int

public init(
allowOnlyShiftModifier: Bool = false,
checkMenuCollision: Bool = true,
enableSequences: Bool = false,
maxSequenceLength: Int = 2
) {
self.allowOnlyShiftModifier = allowOnlyShiftModifier
self.checkMenuCollision = checkMenuCollision
self.enableSequences = enableSequences
self.maxSequenceLength = maxSequenceLength
}
}

/**
A `NSView` that lets the user record a keyboard shortcut.

Expand All @@ -27,13 +54,24 @@
```
*/
public final class RecorderCocoa: NSSearchField, NSSearchFieldDelegate {
public enum StorageMode {
// `.persist` mode does not support sequences.
case persist(Name, onChange: ((_ shortcut: Shortcut?) -> Void)?)
// `.binding` mode can support sequences.
case binding(get: () -> [Shortcut], set: ([Shortcut]) -> Void)
}
private let minimumWidth = 130.0
private let onChange: ((_ shortcut: Shortcut?) -> Void)?
private let storageMode: StorageMode
private var canBecomeKey = false
private var eventMonitor: LocalEventMonitor?
private var shortcutsNameChangeObserver: NSObjectProtocol?
private var windowDidResignKeyObserver: NSObjectProtocol?
private var windowDidBecomeKeyObserver: NSObjectProtocol?
private let recorderOption: RecorderOption

// For sequence recording.
private var recordedChords: [Shortcut] = []
private var recordingTimer: Timer?

/**
The shortcut name for the recorder.
Expand Down Expand Up @@ -83,10 +121,13 @@
*/
public required init(
for name: Name,
onChange: ((_ shortcut: Shortcut?) -> Void)? = nil
onChange: ((_ shortcut: Shortcut?) -> Void)? = nil,

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

View workflow job for this annotation

GitHub Actions / lint

Vertical Parameter Alignment Violation: Function parameters should be aligned vertically if they're in multiple lines in a declaration (vertical_parameter_alignment)
option: RecorderOption = RecorderOption()

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

View workflow job for this annotation

GitHub Actions / lint

Vertical Parameter Alignment Violation: Function parameters should be aligned vertically if they're in multiple lines in a declaration (vertical_parameter_alignment)
) {
precondition(!option.enableSequences, "The `.persist` storage mode for KeyboardShortcuts.RecorderCocoa does not support sequences.")
self.shortcutName = name
self.onChange = onChange
self.storageMode = .persist(name, onChange: onChange)
self.recorderOption = option

super.init(frame: .zero)
self.delegate = self
Expand All @@ -106,6 +147,39 @@
setUpEvents()
}

public init(
get: @escaping () -> [Shortcut],
set: @escaping ([Shortcut]) -> Void,

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

View workflow job for this annotation

GitHub Actions / lint

Vertical Parameter Alignment Violation: Function parameters should be aligned vertically if they're in multiple lines in a declaration (vertical_parameter_alignment)
option: RecorderOption = RecorderOption()

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

View workflow job for this annotation

GitHub Actions / lint

Vertical Parameter Alignment Violation: Function parameters should be aligned vertically if they're in multiple lines in a declaration (vertical_parameter_alignment)
) {
self.storageMode = .binding(get: get, set: set)
// Not used in binding mode, but must be initialized.
self.shortcutName = .init("_binding")
self.recorderOption = option

super.init(frame: .zero)
self.delegate = self
self.placeholderString = "record_shortcut".localized
self.alignment = .center
(cell as? NSSearchFieldCell)?.searchButtonCell = nil

self.wantsLayer = true
setContentHuggingPriority(.defaultHigh, for: .vertical)
setContentHuggingPriority(.defaultHigh, for: .horizontal)

// Hide the cancel button when not showing the shortcut so the placeholder text is properly centered. Must be last.
self.cancelButton = (cell as? NSSearchFieldCell)?.cancelButtonCell

// Initialize display from binding source
if case let .binding(get, _) = storageMode {
let current = get()
stringValue = current.presentableDescription
showsCancelButton = !stringValue.isEmpty
}

setUpEvents()
}

@available(*, unavailable)
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
Expand All @@ -119,6 +193,9 @@
}

private func setUpEvents() {
// Only observe name-based changes when in persist mode.
guard case .persist = storageMode else { return }

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

View workflow job for this annotation

GitHub Actions / lint

Conditional Returns on Newline Violation: Conditional statements should always return on the next line (conditional_returns_on_newline)

shortcutsNameChangeObserver = NotificationCenter.default.addObserver(forName: .shortcutByNameDidChange, object: nil, queue: nil) { [weak self] notification in
guard
let self,
Expand Down Expand Up @@ -153,7 +230,8 @@
/// :nodoc:
public func controlTextDidChange(_ object: Notification) {
if stringValue.isEmpty {
saveShortcut(nil)
// Pass an empty array for non-optional bindings.
saveShortcut([])
}

showsCancelButton = !stringValue.isEmpty
Expand Down Expand Up @@ -262,80 +340,100 @@
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, .function]).isEmpty
|| event.specialKey?.isFunctionKey == true,
let shortcut = Shortcut(event: event)
self.recorderOption.allowOnlyShiftModifier
|| (!event.modifiers.subtracting([.shift, .function]).isEmpty
|| event.specialKey?.isFunctionKey == true),
let newShortcut = Shortcut(event: event)
else {
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?
// Perform collision checks for the new chord.
if self.recorderOption.checkMenuCollision, let menuItem = newShortcut.takenByMainMenu {
blur()

NSAlert.showModal(
for: window,
title: String.localizedStringWithFormat("keyboard_shortcut_used_by_menu_item".localized, menuItem.title)
)

NSAlert.showModal(for: window, title: String.localizedStringWithFormat("keyboard_shortcut_used_by_menu_item".localized, menuItem.title))
focus()

return nil
}

// See: https://developer.apple.com/forums/thread/763878?answerId=804374022#804374022
if shortcut.isDisallowed {
if !self.recorderOption.allowOnlyShiftModifier && newShortcut.isDisallowed {
blur()

NSAlert.showModal(
for: window,
title: "keyboard_shortcut_disallowed".localized
)

NSAlert.showModal(for: window, title: "keyboard_shortcut_disallowed".localized)
focus()
return nil
}

if shortcut.isTakenBySystem {
if newShortcut.isTakenBySystem {
blur()

let modalResponse = NSAlert.showModal(
for: window,
title: "keyboard_shortcut_used_by_system".localized,
// TODO: Add button to offer to open the relevant system settings pane for the user.
message: "keyboard_shortcuts_can_be_changed".localized,
buttonTitles: [
"ok".localized,
"force_use_shortcut".localized
]
)

let modalResponse = NSAlert.showModal(for: window, title: "keyboard_shortcut_used_by_system".localized, message: "keyboard_shortcuts_can_be_changed".localized, buttonTitles: ["ok".localized, "force_use_shortcut".localized])
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
}
}

stringValue = "\(shortcut)"
// If sequences are not enabled, finalize recording immediately.
guard self.recorderOption.enableSequences else {
stringValue = newShortcut.description
showsCancelButton = true
saveShortcut([newShortcut])
blur()
return nil
}

// If sequences are enabled, append and wait for the next chord or timeout.
recordedChords.append(newShortcut)

// If the sequence reaches its maximum length, finalize the recording immediately.
if recordedChords.count >= self.recorderOption.maxSequenceLength {
finalizeRecording()
return nil
}

stringValue = recordedChords.presentableDescription + "…"
showsCancelButton = true

saveShortcut(shortcut)
blur()
// Reset the timer. If it fires, the recording is finalized.
recordingTimer?.invalidate()
recordingTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
self?.finalizeRecording()
}

return nil
}.start()

return shouldBecomeFirstResponder
}

private func saveShortcut(_ shortcut: Shortcut?) {
setShortcut(shortcut, for: shortcutName)
onChange?(shortcut)
private func saveShortcut(_ shortcuts: [Shortcut]) {
switch storageMode {
case .persist(_, let onChange):
// `.persist` mode only supports single shortcuts.
let shortcut = shortcuts.first
setShortcut(shortcut, for: shortcutName)
onChange?(shortcut)
case .binding(_, let set):
set(shortcuts)
}
}

private func finalizeRecording() {
recordingTimer?.invalidate()
recordingTimer = nil

guard !recordedChords.isEmpty else {
blur()
return
}

let finalShortcuts = recordedChords
recordedChords = []

stringValue = finalShortcuts.presentableDescription
saveShortcut(finalShortcuts)
blur()
}
}
}
Expand Down
24 changes: 24 additions & 0 deletions Sources/KeyboardShortcuts/Shortcut.swift
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,30 @@ extension KeyboardShortcuts.Shortcut: CustomStringConvertible {
}
}


// Represents a sequence of keyboard shortcuts, like `ctrl-k ctrl-s`.
// This is useful for recording and representing multi-step shortcuts.
// For example, a sequence can represent pressing “Control-K” followed by “Control-S”.
public struct ShortcutSequence: Codable, Hashable, Sendable {
public var shortcuts: [KeyboardShortcuts.Shortcut]

public init(_ shortcuts: [KeyboardShortcuts.Shortcut]) {
self.shortcuts = shortcuts
}

@MainActor
public var presentableDescription: String {
shortcuts.map(\.presentableDescription).joined(separator: " ")
}
}

extension [KeyboardShortcuts.Shortcut] {
@MainActor
var presentableDescription: String {
map(\.presentableDescription).joined(separator: " ")
}
}

extension KeyboardShortcuts.Shortcut {
@available(macOS 11, *)
@MainActor
Expand Down
Loading