Skip to content

Commit 6805b18

Browse files
kevintraverclaude
andcommitted
feat: add visual keyboard layout editor and cheatsheet
Adds a visual keyboard interface for both editing and viewing shortcuts: - Keyboard Editor: A new Settings tab that displays a full QWERTY keyboard. Click any key to add or edit actions and groups directly on the layout. - Keyboard Cheatsheet: An alternative to the list-style cheatsheet that shows your bindings overlaid on a keyboard. Toggle between styles in preferences. Technical Details: - New KeyboardLayout module: KeyboardLayoutModel, KeyboardLayoutView, KeyView - KeyboardCheatsheetView for overlay display of bindings - KeyboardPane settings tab with unified editor for actions and groups - Modifier key monitoring in Controller for real-time shift state - Cheatsheet style preference stored in UserDefaults - Extracted reusable components: KeyBadge, CheatsheetRows 🤖 Generated with assistance from [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 37f7de0 commit 6805b18

18 files changed

Lines changed: 1756 additions & 181 deletions

Leader Key.xcodeproj/project.pbxproj

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@
4141
427C18502BD6652500955B98 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C184F2BD6652500955B98 /* Util.swift */; };
4242
427C18542BD6E59300955B98 /* NSWindow+Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C18532BD6E59300955B98 /* NSWindow+Animations.swift */; };
4343
4284834C2E813212009D7EEF /* KeyboardLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */; };
44+
4284834E2E813214009D7EEF /* KeyboardCheatsheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4284834D2E813213009D7EEF /* KeyboardCheatsheetView.swift */; };
45+
428483502E813216009D7EEF /* KeyboardLayoutModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4284834F2E813215009D7EEF /* KeyboardLayoutModel.swift */; };
46+
428483522E813218009D7EEF /* KeyboardLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428483512E813217009D7EEF /* KeyboardLayoutView.swift */; };
47+
428483542E81321A009D7EEF /* KeyboardPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428483532E813219009D7EEF /* KeyboardPane.swift */; };
48+
428483562E81321C009D7EEF /* KeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428483572E81321D009D7EEF /* KeyView.swift */; };
49+
428483582E81321E009D7EEF /* KeyBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428483592E81321F009D7EEF /* KeyBadge.swift */; };
50+
4284835A2E813220009D7EEF /* CheatsheetRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4284835B2E813221009D7EEF /* CheatsheetRows.swift */; };
4451
42B21FBC2D67566100F4A2C7 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B21FBB2D67566100F4A2C7 /* Alerts.swift */; };
4552
42CCB5A32DAD257700356FC0 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = FBCA04D82D9F02F700271163 /* Kingfisher */; };
4653
42DFCD722D5B7D48002EA111 /* Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DFCD712D5B7D46002EA111 /* Events.swift */; };
@@ -102,6 +109,13 @@
102109
427C184F2BD6652500955B98 /* Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Util.swift; sourceTree = "<group>"; };
103110
427C18532BD6E59300955B98 /* NSWindow+Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Animations.swift"; sourceTree = "<group>"; };
104111
4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayoutTests.swift; sourceTree = "<group>"; };
112+
4284834D2E813213009D7EEF /* KeyboardCheatsheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardCheatsheetView.swift; sourceTree = "<group>"; };
113+
4284834F2E813215009D7EEF /* KeyboardLayoutModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayoutModel.swift; sourceTree = "<group>"; };
114+
428483512E813217009D7EEF /* KeyboardLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayoutView.swift; sourceTree = "<group>"; };
115+
428483532E813219009D7EEF /* KeyboardPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPane.swift; sourceTree = "<group>"; };
116+
428483572E81321D009D7EEF /* KeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyView.swift; sourceTree = "<group>"; };
117+
428483592E81321F009D7EEF /* KeyBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyBadge.swift; sourceTree = "<group>"; };
118+
4284835B2E813221009D7EEF /* CheatsheetRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatsheetRows.swift; sourceTree = "<group>"; };
105119
42B21FBB2D67566100F4A2C7 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
106120
42DFCD712D5B7D46002EA111 /* Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Events.swift; sourceTree = "<group>"; };
107121
42F4CDC82D458FF700D0DD76 /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenu.swift; sourceTree = "<group>"; };
@@ -158,10 +172,31 @@
158172
children = (
159173
427C18242BD31E2E00955B98 /* GeneralPane.swift */,
160174
42FDC3192D51687B004F5C5C /* AdvancedPane.swift */,
175+
428483532E813219009D7EEF /* KeyboardPane.swift */,
161176
);
162177
path = Settings;
163178
sourceTree = "<group>";
164179
};
180+
428483552E81321B009D7EEF /* KeyboardLayout */ = {
181+
isa = PBXGroup;
182+
children = (
183+
4284834D2E813213009D7EEF /* KeyboardCheatsheetView.swift */,
184+
4284834F2E813215009D7EEF /* KeyboardLayoutModel.swift */,
185+
428483512E813217009D7EEF /* KeyboardLayoutView.swift */,
186+
428483572E81321D009D7EEF /* KeyView.swift */,
187+
);
188+
path = KeyboardLayout;
189+
sourceTree = "<group>";
190+
};
191+
4284835C2E813222009D7EEF /* Cheatsheet */ = {
192+
isa = PBXGroup;
193+
children = (
194+
428483592E81321F009D7EEF /* KeyBadge.swift */,
195+
4284835B2E813221009D7EEF /* CheatsheetRows.swift */,
196+
);
197+
path = Cheatsheet;
198+
sourceTree = "<group>";
199+
};
165200
427C17DE2BD311B400955B98 = {
166201
isa = PBXGroup;
167202
children = (
@@ -205,6 +240,8 @@
205240
427C182E2BD3206200955B98 /* UserState.swift */,
206241
427C184F2BD6652500955B98 /* Util.swift */,
207242
427C17EE2BD311B500955B98 /* Assets.xcassets */,
243+
4284835C2E813222009D7EEF /* Cheatsheet */,
244+
428483552E81321B009D7EEF /* KeyboardLayout */,
208245
423632242D68CC5D00878D92 /* Settings */,
209246
427C18362BD3243C00955B98 /* Support */,
210247
423632232D68CB0F00878D92 /* Themes */,
@@ -443,6 +480,13 @@
443480
42F4CDCD2D45B13600D0DD76 /* KeyButton.swift in Sources */,
444481
606C56EF2DAB875A00198B9F /* Cheater.swift in Sources */,
445482
427C181C2BD314B500955B98 /* Constants.swift in Sources */,
483+
4284834E2E813214009D7EEF /* KeyboardCheatsheetView.swift in Sources */,
484+
428483502E813216009D7EEF /* KeyboardLayoutModel.swift in Sources */,
485+
428483522E813218009D7EEF /* KeyboardLayoutView.swift in Sources */,
486+
428483542E81321A009D7EEF /* KeyboardPane.swift in Sources */,
487+
428483562E81321C009D7EEF /* KeyView.swift in Sources */,
488+
428483582E81321E009D7EEF /* KeyBadge.swift in Sources */,
489+
4284835A2E813220009D7EEF /* CheatsheetRows.swift in Sources */,
446490
);
447491
runOnlyForDeploymentPostprocessing = 0;
448492
};

Leader Key/AppDelegate.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ class AppDelegate: NSObject, NSApplicationDelegate,
3535
contentView: {
3636
AdvancedPane().environmentObject(self.config)
3737
}),
38+
Settings.Pane(
39+
identifier: .keyboard, title: "Keyboard",
40+
toolbarIcon: NSImage(
41+
systemSymbolName: "keyboard", accessibilityDescription: "Keyboard")!,
42+
contentView: {
43+
KeyboardPane().environmentObject(self.config)
44+
}),
3845
],
3946
style: .segmentedControl,
4047
)

Leader Key/Cheatsheet.swift

Lines changed: 78 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -3,195 +3,111 @@ import Kingfisher
33
import SwiftUI
44

55
enum Cheatsheet {
6-
private static let iconSize = NSSize(width: 24, height: 24)
6+
static func createWindow(for userState: UserState) -> NSWindow {
7+
let view = CheatsheetView().environmentObject(userState)
8+
let controller = NSHostingController(rootView: view)
9+
controller.sizingOptions = .preferredContentSize
10+
let cheatsheet = PanelWindow(
11+
contentRect: NSRect(x: 0, y: 0, width: 700, height: 640)
12+
)
13+
cheatsheet.contentViewController = controller
14+
return cheatsheet
15+
}
16+
}
717

8-
struct KeyBadge: SwiftUI.View {
9-
let key: String
18+
struct CheatsheetView: SwiftUI.View {
19+
@EnvironmentObject var userState: UserState
20+
@Default(.cheatsheetStyle) var cheatsheetStyle
21+
@State private var contentHeight: CGFloat = 0
1022

11-
var body: some SwiftUI.View {
12-
Text(KeyMaps.glyph(for: key) ?? key)
13-
.font(.system(.body, design: .rounded))
14-
.multilineTextAlignment(.center)
15-
.fontWeight(.bold)
16-
.padding(.vertical, 4)
17-
.frame(width: 24)
18-
.background(.white.opacity(0.1))
19-
.clipShape(RoundedRectangle(cornerRadius: 5.0, style: .continuous))
23+
var maxHeight: CGFloat {
24+
if let screen = NSScreen.main {
25+
return screen.visibleFrame.height - 40
2026
}
27+
return 640
2128
}
2229

23-
struct ActionRow: SwiftUI.View {
24-
let action: Action
25-
let indent: Int
26-
@Default(.showDetailsInCheatsheet) var showDetails
27-
@Default(.showAppIconsInCheatsheet) var showIcons
28-
29-
var body: some SwiftUI.View {
30-
HStack {
31-
HStack {
32-
ForEach(0..<indent, id: \.self) { _ in
33-
Text(" ")
34-
}
35-
KeyBadge(key: action.key ?? "")
36-
37-
if showIcons {
38-
actionIcon(item: ActionOrGroup.action(action), iconSize: iconSize)
39-
}
40-
41-
Text(action.displayName)
42-
.lineLimit(1)
43-
.truncationMode(.middle)
44-
}
45-
Spacer()
46-
if showDetails {
47-
Text(action.value)
48-
.foregroundStyle(.secondary)
49-
.lineLimit(1)
50-
.truncationMode(.middle)
51-
}
52-
}
30+
// Constrain to edge of screen
31+
static var preferredWidth: CGFloat {
32+
if let screen = NSScreen.main {
33+
let screenHalf = screen.visibleFrame.width / 2
34+
let desiredWidth: CGFloat = 700
35+
let margin: CGFloat = 20
36+
return desiredWidth > screenHalf ? screenHalf - margin : desiredWidth
5337
}
38+
return 700
5439
}
5540

56-
struct GroupRow: SwiftUI.View {
57-
@Default(.expandGroupsInCheatsheet) var expand
58-
@Default(.showDetailsInCheatsheet) var showDetails
59-
@Default(.showAppIconsInCheatsheet) var showIcons
41+
var actions: [ActionOrGroup] {
42+
(userState.currentGroup != nil)
43+
? userState.currentGroup!.actions : userState.userConfig.root.actions
44+
}
6045

61-
let group: Group
62-
let indent: Int
46+
var body: some SwiftUI.View {
47+
switch cheatsheetStyle {
48+
case .list:
49+
listView
50+
case .keyboard:
51+
KeyboardCheatsheetView()
52+
.fixedSize()
53+
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
54+
}
55+
}
6356

64-
var body: some SwiftUI.View {
57+
var listView: some View {
58+
ScrollView {
6559
VStack(alignment: .leading, spacing: 4) {
66-
HStack {
67-
ForEach(0..<indent, id: \.self) { _ in
68-
Text(" ")
69-
}
70-
KeyBadge(key: group.key ?? "")
71-
72-
if showIcons {
73-
actionIcon(item: ActionOrGroup.group(group), iconSize: iconSize)
74-
}
75-
76-
Image(systemName: "chevron.right")
77-
.foregroundStyle(.secondary)
78-
79-
Text(group.displayName)
80-
81-
Spacer()
82-
if showDetails {
83-
Text("\(group.actions.count.description) item(s)")
60+
if let group = userState.currentGroup {
61+
HStack {
62+
KeyBadge(key: group.key ?? "")
63+
Text(group.key == nil ? "Leader Key" : group.displayName)
8464
.foregroundStyle(.secondary)
85-
.lineLimit(1)
86-
.truncationMode(.middle)
8765
}
88-
}
89-
if expand {
90-
ForEach(Array(group.actions.enumerated()), id: \.offset) { _, item in
91-
switch item {
92-
case .action(let action):
93-
Cheatsheet.ActionRow(action: action, indent: indent + 1)
94-
case .group(let group):
95-
Cheatsheet.GroupRow(group: group, indent: indent + 1)
96-
}
97-
}
98-
}
99-
}
100-
}
101-
}
102-
103-
struct CheatsheetView: SwiftUI.View {
104-
@EnvironmentObject var userState: UserState
105-
@State private var contentHeight: CGFloat = 0
106-
107-
var maxHeight: CGFloat {
108-
if let screen = NSScreen.main {
109-
return screen.visibleFrame.height - 40 // Leave some margin
110-
}
111-
return 640
112-
}
113-
114-
// Constrain to edge of screen
115-
static var preferredWidth: CGFloat {
116-
if let screen = NSScreen.main {
117-
let screenHalf = screen.visibleFrame.width / 2
118-
let desiredWidth: CGFloat = 580
119-
let margin: CGFloat = 20
120-
return desiredWidth > screenHalf ? screenHalf - margin : desiredWidth
121-
}
122-
return 580
123-
}
124-
125-
var actions: [ActionOrGroup] {
126-
(userState.currentGroup != nil)
127-
? userState.currentGroup!.actions : userState.userConfig.root.actions
128-
}
129-
130-
var body: some SwiftUI.View {
131-
ScrollView {
132-
SwiftUI.VStack(alignment: .leading, spacing: 4) {
133-
if let group = userState.currentGroup {
134-
HStack {
135-
KeyBadge(key: group.key ?? "")
136-
Text(group.key == nil ? "Leader Key" : group.displayName)
137-
.foregroundStyle(.secondary)
138-
}
66+
.padding(.bottom, 8)
67+
Divider()
13968
.padding(.bottom, 8)
140-
Divider()
141-
.padding(.bottom, 8)
142-
}
69+
}
14370

144-
ForEach(Array(actions.enumerated()), id: \.offset) { _, item in
145-
switch item {
146-
case .action(let action):
147-
Cheatsheet.ActionRow(action: action, indent: 0)
148-
case .group(let group):
149-
Cheatsheet.GroupRow(group: group, indent: 0)
150-
}
71+
ForEach(Array(actions.enumerated()), id: \.offset) { _, item in
72+
switch item {
73+
case .action(let action):
74+
ActionRow(action: action, indent: 0)
75+
case .group(let group):
76+
GroupRow(group: group, indent: 0)
15177
}
15278
}
153-
.padding()
154-
.overlay(
155-
GeometryReader { geo in
156-
Color.clear.preference(
157-
key: HeightPreferenceKey.self,
158-
value: geo.size.height
159-
)
160-
}
161-
)
16279
}
163-
.frame(width: Cheatsheet.CheatsheetView.preferredWidth)
164-
.frame(height: min(contentHeight, maxHeight))
165-
.background(
166-
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
80+
.padding()
81+
.overlay(
82+
GeometryReader { geo in
83+
Color.clear.preference(
84+
key: HeightPreferenceKey.self,
85+
value: geo.size.height
86+
)
87+
}
16788
)
168-
.onPreferenceChange(HeightPreferenceKey.self) { height in
169-
self.contentHeight = height
170-
}
17189
}
172-
}
173-
174-
struct HeightPreferenceKey: PreferenceKey {
175-
static var defaultValue: CGFloat = 0
176-
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
177-
value = nextValue()
90+
.frame(width: CheatsheetView.preferredWidth)
91+
.frame(height: min(contentHeight, maxHeight))
92+
.background(
93+
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
94+
)
95+
.onPreferenceChange(HeightPreferenceKey.self) { height in
96+
self.contentHeight = height
17897
}
17998
}
99+
}
180100

181-
static func createWindow(for userState: UserState) -> NSWindow {
182-
let view = CheatsheetView().environmentObject(userState)
183-
let controller = NSHostingController(rootView: view)
184-
let cheatsheet = PanelWindow(
185-
contentRect: NSRect(x: 0, y: 0, width: 580, height: 640)
186-
)
187-
cheatsheet.contentViewController = controller
188-
return cheatsheet
101+
struct HeightPreferenceKey: PreferenceKey {
102+
static var defaultValue: CGFloat = 0
103+
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
104+
value = nextValue()
189105
}
190106
}
191107

192108
struct CheatsheetView_Previews: PreviewProvider {
193109
static var previews: some View {
194-
Cheatsheet.CheatsheetView()
110+
CheatsheetView()
195111
.environmentObject(UserState(userConfig: UserConfig()))
196112
}
197-
}
113+
}

0 commit comments

Comments
 (0)