Skip to content

Commit 5e508ab

Browse files
committed
Add binding support to Recorder
Fixes #164 Fixes #225
1 parent 0a43875 commit 5e508ab

File tree

7 files changed

+451
-59
lines changed

7 files changed

+451
-59
lines changed

Example/KeyboardShortcutsExample/MainScreen.swift

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,33 @@ private struct DoubleShortcut: View {
122122
}
123123
}
124124

125+
private struct BindingShortcut: View {
126+
@State private var shortcut: KeyboardShortcuts.Shortcut?
127+
128+
var body: some View {
129+
Form {
130+
KeyboardShortcuts.Recorder("Binding Shortcut:", shortcut: $shortcut)
131+
Text("Current: \(shortcut.map { "\($0)" } ?? "None")")
132+
.frame(maxWidth: .infinity, alignment: .leading)
133+
Button("Clear") {
134+
shortcut = nil
135+
}
136+
}
137+
.frame(maxWidth: 300)
138+
.padding()
139+
}
140+
}
141+
125142
struct MainScreen: View {
126143
var body: some View {
127144
VStack {
128145
DoubleShortcut()
129146
Divider()
147+
BindingShortcut()
148+
Divider()
130149
DynamicShortcut()
131150
}
132-
.frame(width: 400, height: 320)
151+
.frame(width: 400, height: 520)
133152
}
134153
}
135154

Sources/KeyboardShortcuts/Name.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,14 @@ extension KeyboardShortcuts {
3434
/**
3535
- Parameter name: Name of the shortcut.
3636
- Parameter initialShortcut: Optional default key combination. Do not set this unless it's essential. Users find it annoying when random apps steal their existing keyboard shortcuts. It's generally better to show a welcome screen on the first app launch that lets the user set the shortcut.
37+
- Important: The name must not contain a dot (`.`) because it is used as a key path for observation.
3738
*/
3839
public init(_ name: String, default initialShortcut: Shortcut? = nil) {
40+
runtimeWarn(
41+
KeyboardShortcuts.isValidShortcutName(name),
42+
"The keyboard shortcut name must not contain a dot (.)."
43+
)
44+
3945
self.rawValue = name
4046
self.defaultShortcut = initialShortcut
4147

@@ -51,6 +57,13 @@ extension KeyboardShortcuts {
5157
}
5258
}
5359

60+
extension KeyboardShortcuts.Name {
61+
init(rawValueWithoutInitialization rawValue: String) {
62+
self.rawValue = rawValue
63+
self.defaultShortcut = nil
64+
}
65+
}
66+
5467
extension KeyboardShortcuts.Name: RawRepresentable {
5568
/// :nodoc:
5669
public init?(rawValue: String) {

Sources/KeyboardShortcuts/Recorder.swift

Lines changed: 170 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,74 @@
22
import SwiftUI
33

44
extension KeyboardShortcuts {
5+
enum ShortcutSource {
6+
case name(Name)
7+
case binding(Binding<Shortcut?>)
8+
9+
var binding: Binding<Shortcut?>? {
10+
if case .binding(let binding) = self { binding } else { nil }
11+
}
12+
}
13+
514
private struct _Recorder: NSViewRepresentable { // swiftlint:disable:this type_name
615
typealias NSViewType = RecorderCocoa
716

8-
let name: Name
17+
let source: ShortcutSource
918
let onChange: ((_ shortcut: Shortcut?) -> Void)?
1019

20+
final class Coordinator {
21+
var shortcutBinding: Binding<Shortcut?>?
22+
var onChange: ((_ shortcut: Shortcut?) -> Void)?
23+
24+
init(
25+
shortcutBinding: Binding<Shortcut?>?,
26+
onChange: ((_ shortcut: Shortcut?) -> Void)?
27+
) {
28+
self.shortcutBinding = shortcutBinding
29+
self.onChange = onChange
30+
}
31+
32+
func handleChange(_ shortcut: Shortcut?) {
33+
shortcutBinding?.wrappedValue = shortcut
34+
onChange?(shortcut)
35+
}
36+
}
37+
38+
func makeCoordinator() -> Coordinator {
39+
.init(shortcutBinding: source.binding, onChange: onChange)
40+
}
41+
1142
func makeNSView(context: Context) -> NSViewType {
12-
.init(for: name, onChange: onChange)
43+
let coordinator = context.coordinator
44+
45+
switch source {
46+
case .name(let name):
47+
return .init(for: name) { shortcut in
48+
coordinator.handleChange(shortcut)
49+
}
50+
case .binding(let binding):
51+
return .init(shortcut: binding.wrappedValue) { shortcut in
52+
coordinator.handleChange(shortcut)
53+
}
54+
}
1355
}
1456

1557
func updateNSView(_ nsView: NSViewType, context: Context) {
16-
nsView.shortcutName = name
58+
let coordinator = context.coordinator
59+
coordinator.onChange = onChange
60+
61+
switch source {
62+
case .name(let name):
63+
nsView.shortcutName = name
64+
case .binding(let binding):
65+
coordinator.shortcutBinding = binding
66+
67+
guard nsView.shortcut != binding.wrappedValue else {
68+
return
69+
}
70+
71+
nsView.shortcut = binding.wrappedValue
72+
}
1773
}
1874
}
1975

@@ -24,7 +80,9 @@ extension KeyboardShortcuts {
2480

2581
It automatically prevents choosing a keyboard shortcut that is already taken by the system or by the app's main menu by showing a user-friendly alert to the user.
2682

27-
It takes care of storing the keyboard shortcut in `UserDefaults` for you.
83+
It takes care of storing the keyboard shortcut in `UserDefaults` for you when initialized with a name. When initialized with a binding, it reads and writes the shortcut through the binding.
84+
85+
- Note: When initialized with a binding, the shortcut is not automatically registered as a global hotkey. You are responsible for storing and handling the shortcut yourself.
2886

2987
```swift
3088
import SwiftUI
@@ -42,18 +100,18 @@ extension KeyboardShortcuts {
42100
- Note: Since macOS 15, for sandboxed apps, it's [no longer possible](https://developer.apple.com/forums/thread/763878?answerId=804374022#804374022) to specify the `Option` key without also using `Command` or `Control`.
43101
*/
44102
public struct Recorder<Label: View>: View { // swiftlint:disable:this type_name
45-
private let name: Name
103+
private let shortcutSource: ShortcutSource
46104
private let onChange: ((Shortcut?) -> Void)?
47105
private let hasLabel: Bool
48106
private let label: Label
49107

50-
init(
51-
for name: Name,
108+
private init(
109+
shortcutSource: ShortcutSource,
52110
onChange: ((Shortcut?) -> Void)? = nil,
53111
hasLabel: Bool,
54112
@ViewBuilder label: () -> Label
55113
) {
56-
self.name = name
114+
self.shortcutSource = shortcutSource
57115
self.onChange = onChange
58116
self.hasLabel = hasLabel
59117
self.label = label()
@@ -63,29 +121,27 @@ extension KeyboardShortcuts {
63121
if hasLabel {
64122
if #available(macOS 13, *) {
65123
LabeledContent {
66-
_Recorder(
67-
name: name,
68-
onChange: onChange
69-
)
124+
recorderView
70125
} label: {
71126
label
72127
}
73128
} else {
74-
_Recorder(
75-
name: name,
76-
onChange: onChange
77-
)
129+
recorderView
78130
.formLabel {
79131
label
80132
}
81133
}
82134
} else {
83-
_Recorder(
84-
name: name,
85-
onChange: onChange
86-
)
135+
recorderView
87136
}
88137
}
138+
139+
private var recorderView: some View {
140+
_Recorder(
141+
source: shortcutSource,
142+
onChange: onChange
143+
)
144+
}
89145
}
90146
}
91147

@@ -99,14 +155,47 @@ extension KeyboardShortcuts.Recorder<EmptyView> {
99155
onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil
100156
) {
101157
self.init(
102-
for: name,
158+
shortcutSource: .name(name),
159+
onChange: onChange,
160+
hasLabel: false
161+
) {}
162+
}
163+
164+
/**
165+
Creates a keyboard shortcut recorder that reads and writes to a binding.
166+
167+
Use this initializer when you want to manage the shortcut storage yourself instead of using the built-in `UserDefaults` storage. The shortcut is not automatically registered as a global hotkey — you are responsible for storing and handling the shortcut yourself.
168+
169+
- Parameter shortcut: The keyboard shortcut binding to read and write.
170+
- Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user.
171+
*/
172+
public init(
173+
shortcut: Binding<KeyboardShortcuts.Shortcut?>,
174+
onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil
175+
) {
176+
self.init(
177+
shortcutSource: .binding(shortcut),
103178
onChange: onChange,
104179
hasLabel: false
105180
) {}
106181
}
107182
}
108183

109184
extension KeyboardShortcuts.Recorder<Text> {
185+
private init(
186+
_ title: Text,
187+
source: KeyboardShortcuts.ShortcutSource,
188+
onChange: ((KeyboardShortcuts.Shortcut?) -> Void)?
189+
) {
190+
self.init(
191+
shortcutSource: source,
192+
onChange: onChange,
193+
hasLabel: true
194+
) {
195+
title
196+
}
197+
}
198+
110199
/**
111200
- Parameter title: The title of the keyboard shortcut recorder, describing its purpose.
112201
- Parameter name: Strongly-typed keyboard shortcut name.
@@ -117,17 +206,9 @@ extension KeyboardShortcuts.Recorder<Text> {
117206
name: KeyboardShortcuts.Name,
118207
onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil
119208
) {
120-
self.init(
121-
for: name,
122-
onChange: onChange,
123-
hasLabel: true
124-
) {
125-
Text(title)
126-
}
209+
self.init(Text(title), source: .name(name), onChange: onChange)
127210
}
128-
}
129211

130-
extension KeyboardShortcuts.Recorder<Text> {
131212
/**
132213
- Parameter title: The title of the keyboard shortcut recorder, describing its purpose.
133214
- Parameter name: Strongly-typed keyboard shortcut name.
@@ -139,13 +220,42 @@ extension KeyboardShortcuts.Recorder<Text> {
139220
name: KeyboardShortcuts.Name,
140221
onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil
141222
) {
142-
self.init(
143-
for: name,
144-
onChange: onChange,
145-
hasLabel: true
146-
) {
147-
Text(title)
148-
}
223+
self.init(Text(title), source: .name(name), onChange: onChange)
224+
}
225+
226+
/**
227+
Creates a keyboard shortcut recorder that reads and writes to a binding.
228+
229+
Use this initializer when you want to manage the shortcut storage yourself instead of using the built-in `UserDefaults` storage. The shortcut is not automatically registered as a global hotkey — you are responsible for storing and handling the shortcut yourself.
230+
231+
- Parameter title: The title of the keyboard shortcut recorder, describing its purpose.
232+
- Parameter shortcut: The keyboard shortcut binding to read and write.
233+
- Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user.
234+
*/
235+
public init(
236+
_ title: LocalizedStringKey,
237+
shortcut: Binding<KeyboardShortcuts.Shortcut?>,
238+
onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil
239+
) {
240+
self.init(Text(title), source: .binding(shortcut), onChange: onChange)
241+
}
242+
243+
/**
244+
Creates a keyboard shortcut recorder that reads and writes to a binding.
245+
246+
Use this initializer when you want to manage the shortcut storage yourself instead of using the built-in `UserDefaults` storage. The shortcut is not automatically registered as a global hotkey — you are responsible for storing and handling the shortcut yourself.
247+
248+
- Parameter title: The title of the keyboard shortcut recorder, describing its purpose.
249+
- Parameter shortcut: The keyboard shortcut binding to read and write.
250+
- Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user.
251+
*/
252+
@_disfavoredOverload
253+
public init(
254+
_ title: String,
255+
shortcut: Binding<KeyboardShortcuts.Shortcut?>,
256+
onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil
257+
) {
258+
self.init(Text(title), source: .binding(shortcut), onChange: onChange)
149259
}
150260
}
151261

@@ -161,7 +271,29 @@ extension KeyboardShortcuts.Recorder {
161271
@ViewBuilder label: () -> Label
162272
) {
163273
self.init(
164-
for: name,
274+
shortcutSource: .name(name),
275+
onChange: onChange,
276+
hasLabel: true,
277+
label: label
278+
)
279+
}
280+
281+
/**
282+
Creates a keyboard shortcut recorder that reads and writes to a binding.
283+
284+
Use this initializer when you want to manage the shortcut storage yourself instead of using the built-in `UserDefaults` storage. The shortcut is not automatically registered as a global hotkey — you are responsible for storing and handling the shortcut yourself.
285+
286+
- Parameter shortcut: The keyboard shortcut binding to read and write.
287+
- Parameter onChange: Callback which will be called when the keyboard shortcut is changed/removed by the user.
288+
- Parameter label: A view that describes the purpose of the keyboard shortcut recorder.
289+
*/
290+
public init(
291+
shortcut: Binding<KeyboardShortcuts.Shortcut?>,
292+
onChange: ((KeyboardShortcuts.Shortcut?) -> Void)? = nil,
293+
@ViewBuilder label: () -> Label
294+
) {
295+
self.init(
296+
shortcutSource: .binding(shortcut),
165297
onChange: onChange,
166298
hasLabel: true,
167299
label: label

0 commit comments

Comments
 (0)