Skip to content

Commit 9756853

Browse files
authored
Rewrite the editor, add drag'n'drop, add sorting (#244)
* Show in dock/cmdtab when settings are visible * Format * Simplify activation logic * Update AGENTS * Changed bundle id? * Update Claude config * Rebuild the editor for performance + features
1 parent 3bec501 commit 9756853

13 files changed

Lines changed: 1605 additions & 591 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
Updates/*
22
*xcuserdata*
33
default.profraw
4+
build/

AGENTS.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# AGENTS.md
2+
3+
This file provides guidance to coding agents when working with code in this repository.
4+
5+
# Leader Key Development Guide
6+
7+
## Build & Test Commands
8+
9+
- Build and run: `xcodebuild -scheme "Leader Key" -configuration Debug build`
10+
- Run all tests: `xcodebuild -scheme "Leader Key" -testPlan "TestPlan" test`
11+
- Run single test: `xcodebuild -scheme "Leader Key" -testPlan "TestPlan" -only-testing:Leader KeyTests/UserConfigTests/testInitializesWithDefaults test`
12+
- Bump version: `bin/bump`
13+
- Create release: `bin/release`
14+
15+
## Architecture Overview
16+
17+
Leader Key is a macOS application that provides customizable keyboard shortcuts. The core architecture consists of:
18+
19+
**Key Components:**
20+
21+
- `AppDelegate`: Application lifecycle, global shortcuts registration, update management
22+
- `Controller`: Central event handling, manages key sequences and window display
23+
- `UserConfig`: JSON configuration management with validation
24+
- `UserState`: Tracks navigation through key sequences
25+
- `MainWindow`: Base class for theme windows
26+
27+
**Theme System:**
28+
29+
- Themes inherit from `MainWindow` and implement `draw()` method
30+
- Available themes: MysteryBox, Mini, Breadcrumbs, ForTheHorde, Cheater
31+
- Each theme provides different visual representations of shortcuts
32+
33+
**Configuration Flow:**
34+
35+
- Config stored at `~/Library/Application Support/Leader Key/config.json`
36+
- `FileMonitor` watches for changes and triggers reload
37+
- `ConfigValidator` ensures no key conflicts
38+
- Actions support: applications, URLs, commands, folders
39+
40+
**Testing Architecture:**
41+
42+
- Uses XCTest with custom `TestAlertManager` for UI testing
43+
- Tests use isolated UserDefaults and temporary directories
44+
- Focus on configuration validation and state management
45+
46+
## Code Style Guidelines
47+
48+
- **Imports**: Group Foundation/AppKit imports first, then third-party libraries (Combine, Defaults)
49+
- **Naming**: Use descriptive camelCase for variables/functions, PascalCase for types
50+
- **Types**: Use explicit type annotations for public properties and parameters
51+
- **Error Handling**: Use appropriate error handling with do/catch blocks and alerts
52+
- **Extensions**: Create extensions for additional functionality on existing types
53+
- **State Management**: Use @Published and ObservableObject for reactive UI updates
54+
- **Testing**: Create separate test cases with descriptive names, use XCTAssert\* methods
55+
- **Access Control**: Use appropriate access modifiers (private, fileprivate, internal)
56+
- **Documentation**: Use comments for complex logic or non-obvious implementations
57+
58+
Follow Swift idioms and default formatting (4-space indentation, spaces around operators).
59+

CLAUDE.md

Lines changed: 0 additions & 21 deletions
This file was deleted.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

Leader Key.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
427C184D2BD65C5C00955B98 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C184C2BD65C5C00955B98 /* Defaults.swift */; };
4040
427C18502BD6652500955B98 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C184F2BD6652500955B98 /* Util.swift */; };
4141
427C18542BD6E59300955B98 /* NSWindow+Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C18532BD6E59300955B98 /* NSWindow+Animations.swift */; };
42-
42A0CE822D2D226800879029 /* ConfigEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A0CE812D2D226800879029 /* ConfigEditorView.swift */; };
42+
6D9B9C012DBA000000000001 /* ConfigOutlineEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */; };
4343
42B21FBC2D67566100F4A2C7 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B21FBB2D67566100F4A2C7 /* Alerts.swift */; };
4444
42CCB5A32DAD257700356FC0 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = FBCA04D82D9F02F700271163 /* Kingfisher */; };
4545
42DFCD722D5B7D48002EA111 /* Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DFCD712D5B7D46002EA111 /* Events.swift */; };
@@ -96,7 +96,7 @@
9696
427C184C2BD65C5C00955B98 /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = "<group>"; };
9797
427C184F2BD6652500955B98 /* Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Util.swift; sourceTree = "<group>"; };
9898
427C18532BD6E59300955B98 /* NSWindow+Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Animations.swift"; sourceTree = "<group>"; };
99-
42A0CE812D2D226800879029 /* ConfigEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigEditorView.swift; sourceTree = "<group>"; };
99+
6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigOutlineEditorView.swift; sourceTree = "<group>"; };
100100
42B21FBB2D67566100F4A2C7 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
101101
42DFCD712D5B7D46002EA111 /* Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Events.swift; sourceTree = "<group>"; };
102102
42F4CDC82D458FF700D0DD76 /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenu.swift; sourceTree = "<group>"; };
@@ -238,7 +238,7 @@
238238
isa = PBXGroup;
239239
children = (
240240
605385A22D523CAD00BEDB4B /* Pulsate.swift */,
241-
42A0CE812D2D226800879029 /* ConfigEditorView.swift */,
241+
6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */,
242242
427C18372BD3262100955B98 /* VisualEffectBackground.swift */,
243243
42F4CDCC2D45B13600D0DD76 /* KeyButton.swift */,
244244
115AA5BE2DA521C200C17E18 /* ActionIcon.swift */,
@@ -420,7 +420,7 @@
420420
423632262D68CDBB00878D92 /* Mini.swift in Sources */,
421421
427C18282BD31E2E00955B98 /* GeneralPane.swift in Sources */,
422422
423632282D6A806700878D92 /* Theme.swift in Sources */,
423-
42A0CE822D2D226800879029 /* ConfigEditorView.swift in Sources */,
423+
6D9B9C012DBA000000000001 /* ConfigOutlineEditorView.swift in Sources */,
424424
42F4CDD12D48C52400D0DD76 /* Extensions.swift in Sources */,
425425
427C182F2BD3206200955B98 /* UserState.swift in Sources */,
426426
427C18202BD31C3D00955B98 /* AppDelegate.swift in Sources */,

Leader Key/AppDelegate.swift

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ let updateLocationIdentifier = "UpdateCheck"
1111
@NSApplicationMain
1212
class AppDelegate: NSObject, NSApplicationDelegate,
1313
SPUStandardUserDriverDelegate,
14-
UNUserNotificationCenterDelegate
14+
UNUserNotificationCenterDelegate,
15+
NSWindowDelegate
1516
{
1617
var controller: Controller!
1718

@@ -40,20 +41,13 @@ class AppDelegate: NSObject, NSApplicationDelegate,
4041
)
4142

4243
func applicationDidFinishLaunching(_: Notification) {
44+
4345
guard
4446
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1"
4547
else { return }
4648
guard !isRunningTests() else { return }
4749

4850
UNUserNotificationCenter.current().delegate = self
49-
UNUserNotificationCenter.current().requestAuthorization(options: [
50-
.alert, .badge, .sound,
51-
]) {
52-
granted, error in
53-
if let error = error {
54-
print("Error requesting notification permission: \(error)")
55-
}
56-
}
5751

5852
NSApp.mainMenu = MainMenu()
5953

@@ -75,8 +69,10 @@ class AppDelegate: NSObject, NSApplicationDelegate,
7569
}
7670

7771
statusItem.handlePreferences = {
78-
self.settingsWindowController.show()
79-
NSApp.activate(ignoringOtherApps: true)
72+
self.showSettings()
73+
}
74+
statusItem.handleAbout = {
75+
NSApp.orderFrontStandardAboutPanel(nil)
8076
}
8177
statusItem.handleReloadConfig = {
8278
self.config.reloadConfig()
@@ -98,6 +94,15 @@ class AppDelegate: NSObject, NSApplicationDelegate,
9894
}
9995
}
10096

97+
// Initialize status item according to current preference
98+
if Defaults[.showMenuBarIcon] {
99+
statusItem.enable()
100+
} else {
101+
statusItem.disable()
102+
}
103+
104+
// Activation policy is managed solely by the Settings window
105+
101106
registerGlobalShortcuts()
102107
}
103108

@@ -143,8 +148,7 @@ class AppDelegate: NSObject, NSApplicationDelegate,
143148

144149
@IBAction
145150
func settingsMenuItemActionHandler(_: NSMenuItem) {
146-
settingsWindowController.show()
147-
NSApp.activate(ignoringOtherApps: true)
151+
showSettings()
148152
}
149153

150154
func show() {
@@ -165,19 +169,22 @@ class AppDelegate: NSObject, NSApplicationDelegate,
165169
_ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem,
166170
state: SPUUserUpdateState
167171
) {
168-
NSApp.setActivationPolicy(.regular)
172+
// Do not change activation policy here; Settings drives visibility
169173

170174
if !state.userInitiated {
171175
NSApp.dockTile.badgeLabel = "1"
172176

173-
let content = UNMutableNotificationContent()
174-
content.title = "Leader Key Update Available"
175-
content.body = "Version \(update.displayVersionString) is now available"
177+
requestNotificationsAuthorizationIfNeeded { granted in
178+
guard granted else { return }
179+
let content = UNMutableNotificationContent()
180+
content.title = "Leader Key Update Available"
181+
content.body = "Version \(update.displayVersionString) is now available"
176182

177-
let request = UNNotificationRequest(
178-
identifier: updateLocationIdentifier, content: content,
179-
trigger: nil)
180-
UNUserNotificationCenter.current().add(request)
183+
let request = UNNotificationRequest(
184+
identifier: updateLocationIdentifier, content: content,
185+
trigger: nil)
186+
UNUserNotificationCenter.current().add(request)
187+
}
181188
}
182189
}
183190

@@ -192,9 +199,7 @@ class AppDelegate: NSObject, NSApplicationDelegate,
192199
])
193200
}
194201

195-
func standardUserDriverWillFinishUpdateSession() {
196-
NSApp.setActivationPolicy(.accessory)
197-
}
202+
func standardUserDriverWillFinishUpdateSession() {}
198203

199204
// MARK: - UNUserNotificationCenter Delegate
200205

@@ -229,6 +234,15 @@ class AppDelegate: NSObject, NSApplicationDelegate,
229234
private func handleURL(_ url: URL) {
230235
guard url.scheme == "leaderkey" else { return }
231236

237+
if url.host == "settings" {
238+
showSettings()
239+
return
240+
}
241+
if url.host == "about" {
242+
NSApp.orderFrontStandardAboutPanel(nil)
243+
return
244+
}
245+
232246
show()
233247

234248
if url.host == "navigate",
@@ -258,4 +272,43 @@ class AppDelegate: NSObject, NSApplicationDelegate,
258272
}
259273
}
260274
}
275+
276+
// MARK: - Activation Policy: Only Settings Visibility Controls It
277+
278+
private func showSettings() {
279+
// Behave like a normal app while Settings is open
280+
NSApp.setActivationPolicy(.regular)
281+
settingsWindowController.show()
282+
NSApp.activate(ignoringOtherApps: true)
283+
settingsWindowController.window?.delegate = self
284+
}
285+
286+
// Revert to accessory when Settings window closes
287+
func windowWillClose(_ notification: Notification) {
288+
guard let win = notification.object as? NSWindow,
289+
win == settingsWindowController.window
290+
else { return }
291+
NSApp.setActivationPolicy(.accessory)
292+
}
293+
294+
private func requestNotificationsAuthorizationIfNeeded(
295+
completion: @escaping (Bool) -> Void
296+
) {
297+
UNUserNotificationCenter.current().getNotificationSettings { settings in
298+
switch settings.authorizationStatus {
299+
case .notDetermined:
300+
UNUserNotificationCenter.current().requestAuthorization(options: [
301+
.alert, .badge, .sound,
302+
]) { granted, _ in
303+
DispatchQueue.main.async { completion(granted) }
304+
}
305+
case .authorized, .provisional, .ephemeral:
306+
DispatchQueue.main.async { completion(true) }
307+
case .denied:
308+
DispatchQueue.main.async { completion(false) }
309+
@unknown default:
310+
DispatchQueue.main.async { completion(false) }
311+
}
312+
}
313+
}
261314
}

Leader Key/MainMenu.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class MainMenu: NSMenu {
1212
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: ""),
1313
.separator(),
1414
NSMenuItem(
15-
title: "Preferences...", action: #selector(AppDelegate.settingsMenuItemActionHandler(_:)),
15+
title: "Settings…", action: #selector(AppDelegate.settingsMenuItemActionHandler(_:)),
1616
keyEquivalent: ","),
1717
.separator(),
1818
NSMenuItem(

Leader Key/Settings/GeneralPane.swift

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,22 @@ struct GeneralPane: View {
99
@EnvironmentObject private var config: UserConfig
1010
@Default(.configDir) var configDir
1111
@Default(.theme) var theme
12-
@State private var expandedGroups = Set<[Int]>()
1312

1413
var body: some View {
1514
Settings.Container(contentWidth: contentWidth) {
1615
Settings.Section(
1716
title: "Config", bottomDivider: true, verticalAlignment: .top
1817
) {
1918
VStack(alignment: .leading, spacing: 8) {
20-
VStack {
21-
ConfigEditorView(group: $config.root, expandedGroups: $expandedGroups)
22-
.frame(height: 500)
23-
// Probably horrible for accessibility but improves performance a ton
24-
.focusable(false)
25-
}
26-
.padding(8)
27-
.overlay(
28-
RoundedRectangle(cornerRadius: 12)
29-
.inset(by: 1)
30-
.stroke(Color.primary, lineWidth: 1)
31-
.opacity(0.1)
32-
)
19+
// AppKit-backed editor for maximum smoothness
20+
ConfigOutlineEditorView(root: $config.root)
21+
.frame(height: 500)
22+
.overlay(
23+
RoundedRectangle(cornerRadius: 12)
24+
.inset(by: 1)
25+
.stroke(Color.primary, lineWidth: 1)
26+
.opacity(0.1)
27+
)
3328

3429
HStack {
3530
// Left-aligned buttons
@@ -48,22 +43,25 @@ struct GeneralPane: View {
4843
// Right-aligned buttons
4944
HStack(spacing: 8) {
5045
Button(action: {
51-
withAnimation(.easeOut(duration: 0.1)) {
52-
expandAllGroups(in: config.root, parentPath: [])
53-
}
46+
NotificationCenter.default.post(name: .lkExpandAll, object: nil)
5447
}) {
5548
Image(systemName: "chevron.down")
5649
Text("Expand all")
5750
}
5851

5952
Button(action: {
60-
withAnimation(.easeOut(duration: 0.1)) {
61-
expandedGroups.removeAll()
62-
}
53+
NotificationCenter.default.post(name: .lkCollapseAll, object: nil)
6354
}) {
6455
Image(systemName: "chevron.right")
6556
Text("Collapse all")
6657
}
58+
59+
Button(action: {
60+
NotificationCenter.default.post(name: .lkSortAZ, object: nil)
61+
}) {
62+
Image(systemName: "arrow.up.arrow.down")
63+
Text("Sort A → Z")
64+
}
6765
}
6866
}
6967
}
@@ -86,16 +84,6 @@ struct GeneralPane: View {
8684
}
8785
}
8886
}
89-
90-
private func expandAllGroups(in group: Group, parentPath: [Int]) {
91-
for (index, item) in group.actions.enumerated() {
92-
let currentPath = parentPath + [index]
93-
if case .group(let subgroup) = item {
94-
expandedGroups.insert(currentPath)
95-
expandAllGroups(in: subgroup, parentPath: currentPath)
96-
}
97-
}
98-
}
9987
}
10088

10189
struct GeneralPane_Previews: PreviewProvider {

0 commit comments

Comments
 (0)