Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Updates/*
*xcuserdata*
default.profraw
build/
59 changes: 59 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# AGENTS.md

This file provides guidance to coding agents when working with code in this repository.

# Leader Key Development Guide

## Build & Test Commands

- Build and run: `xcodebuild -scheme "Leader Key" -configuration Debug build`
- Run all tests: `xcodebuild -scheme "Leader Key" -testPlan "TestPlan" test`
- Run single test: `xcodebuild -scheme "Leader Key" -testPlan "TestPlan" -only-testing:Leader KeyTests/UserConfigTests/testInitializesWithDefaults test`
- Bump version: `bin/bump`
- Create release: `bin/release`

## Architecture Overview

Leader Key is a macOS application that provides customizable keyboard shortcuts. The core architecture consists of:

**Key Components:**

- `AppDelegate`: Application lifecycle, global shortcuts registration, update management
- `Controller`: Central event handling, manages key sequences and window display
- `UserConfig`: JSON configuration management with validation
- `UserState`: Tracks navigation through key sequences
- `MainWindow`: Base class for theme windows

**Theme System:**

- Themes inherit from `MainWindow` and implement `draw()` method
- Available themes: MysteryBox, Mini, Breadcrumbs, ForTheHorde, Cheater
- Each theme provides different visual representations of shortcuts

**Configuration Flow:**

- Config stored at `~/Library/Application Support/Leader Key/config.json`
- `FileMonitor` watches for changes and triggers reload
- `ConfigValidator` ensures no key conflicts
- Actions support: applications, URLs, commands, folders

**Testing Architecture:**

- Uses XCTest with custom `TestAlertManager` for UI testing
- Tests use isolated UserDefaults and temporary directories
- Focus on configuration validation and state management

## Code Style Guidelines

- **Imports**: Group Foundation/AppKit imports first, then third-party libraries (Combine, Defaults)
- **Naming**: Use descriptive camelCase for variables/functions, PascalCase for types
- **Types**: Use explicit type annotations for public properties and parameters
- **Error Handling**: Use appropriate error handling with do/catch blocks and alerts
- **Extensions**: Create extensions for additional functionality on existing types
- **State Management**: Use @Published and ObservableObject for reactive UI updates
- **Testing**: Create separate test cases with descriptive names, use XCTAssert\* methods
- **Access Control**: Use appropriate access modifiers (private, fileprivate, internal)
- **Documentation**: Use comments for complex logic or non-obvious implementations

Follow Swift idioms and default formatting (4-space indentation, spaces around operators).

21 changes: 0 additions & 21 deletions CLAUDE.md

This file was deleted.

1 change: 1 addition & 0 deletions CLAUDE.md
8 changes: 4 additions & 4 deletions Leader Key.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
427C184D2BD65C5C00955B98 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C184C2BD65C5C00955B98 /* Defaults.swift */; };
427C18502BD6652500955B98 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C184F2BD6652500955B98 /* Util.swift */; };
427C18542BD6E59300955B98 /* NSWindow+Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C18532BD6E59300955B98 /* NSWindow+Animations.swift */; };
42A0CE822D2D226800879029 /* ConfigEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A0CE812D2D226800879029 /* ConfigEditorView.swift */; };
6D9B9C012DBA000000000001 /* ConfigOutlineEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */; };
42B21FBC2D67566100F4A2C7 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B21FBB2D67566100F4A2C7 /* Alerts.swift */; };
42CCB5A32DAD257700356FC0 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = FBCA04D82D9F02F700271163 /* Kingfisher */; };
42DFCD722D5B7D48002EA111 /* Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DFCD712D5B7D46002EA111 /* Events.swift */; };
Expand Down Expand Up @@ -96,7 +96,7 @@
427C184C2BD65C5C00955B98 /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = "<group>"; };
427C184F2BD6652500955B98 /* Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Util.swift; sourceTree = "<group>"; };
427C18532BD6E59300955B98 /* NSWindow+Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Animations.swift"; sourceTree = "<group>"; };
42A0CE812D2D226800879029 /* ConfigEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigEditorView.swift; sourceTree = "<group>"; };
6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigOutlineEditorView.swift; sourceTree = "<group>"; };
42B21FBB2D67566100F4A2C7 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
42DFCD712D5B7D46002EA111 /* Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Events.swift; sourceTree = "<group>"; };
42F4CDC82D458FF700D0DD76 /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenu.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -238,7 +238,7 @@
isa = PBXGroup;
children = (
605385A22D523CAD00BEDB4B /* Pulsate.swift */,
42A0CE812D2D226800879029 /* ConfigEditorView.swift */,
6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */,
427C18372BD3262100955B98 /* VisualEffectBackground.swift */,
42F4CDCC2D45B13600D0DD76 /* KeyButton.swift */,
115AA5BE2DA521C200C17E18 /* ActionIcon.swift */,
Expand Down Expand Up @@ -420,7 +420,7 @@
423632262D68CDBB00878D92 /* Mini.swift in Sources */,
427C18282BD31E2E00955B98 /* GeneralPane.swift in Sources */,
423632282D6A806700878D92 /* Theme.swift in Sources */,
42A0CE822D2D226800879029 /* ConfigEditorView.swift in Sources */,
6D9B9C012DBA000000000001 /* ConfigOutlineEditorView.swift in Sources */,
42F4CDD12D48C52400D0DD76 /* Extensions.swift in Sources */,
427C182F2BD3206200955B98 /* UserState.swift in Sources */,
427C18202BD31C3D00955B98 /* AppDelegate.swift in Sources */,
Expand Down
101 changes: 77 additions & 24 deletions Leader Key/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ let updateLocationIdentifier = "UpdateCheck"
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate,
SPUStandardUserDriverDelegate,
UNUserNotificationCenterDelegate
UNUserNotificationCenterDelegate,
NSWindowDelegate
{
var controller: Controller!

Expand Down Expand Up @@ -40,20 +41,13 @@ class AppDelegate: NSObject, NSApplicationDelegate,
)

func applicationDidFinishLaunching(_: Notification) {

guard
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1"
else { return }
guard !isRunningTests() else { return }

UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [
.alert, .badge, .sound,
]) {
granted, error in
if let error = error {
print("Error requesting notification permission: \(error)")
}
}

NSApp.mainMenu = MainMenu()

Expand All @@ -75,8 +69,10 @@ class AppDelegate: NSObject, NSApplicationDelegate,
}

statusItem.handlePreferences = {
self.settingsWindowController.show()
NSApp.activate(ignoringOtherApps: true)
self.showSettings()
}
statusItem.handleAbout = {
NSApp.orderFrontStandardAboutPanel(nil)
}
statusItem.handleReloadConfig = {
self.config.reloadConfig()
Expand All @@ -98,6 +94,15 @@ class AppDelegate: NSObject, NSApplicationDelegate,
}
}

// Initialize status item according to current preference
if Defaults[.showMenuBarIcon] {
statusItem.enable()
} else {
statusItem.disable()
}

// Activation policy is managed solely by the Settings window

registerGlobalShortcuts()
}

Expand Down Expand Up @@ -143,8 +148,7 @@ class AppDelegate: NSObject, NSApplicationDelegate,

@IBAction
func settingsMenuItemActionHandler(_: NSMenuItem) {
settingsWindowController.show()
NSApp.activate(ignoringOtherApps: true)
showSettings()
}

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

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

let content = UNMutableNotificationContent()
content.title = "Leader Key Update Available"
content.body = "Version \(update.displayVersionString) is now available"
requestNotificationsAuthorizationIfNeeded { granted in
guard granted else { return }
let content = UNMutableNotificationContent()
content.title = "Leader Key Update Available"
content.body = "Version \(update.displayVersionString) is now available"

let request = UNNotificationRequest(
identifier: updateLocationIdentifier, content: content,
trigger: nil)
UNUserNotificationCenter.current().add(request)
let request = UNNotificationRequest(
identifier: updateLocationIdentifier, content: content,
trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
}

Expand All @@ -192,9 +199,7 @@ class AppDelegate: NSObject, NSApplicationDelegate,
])
}

func standardUserDriverWillFinishUpdateSession() {
NSApp.setActivationPolicy(.accessory)
}
func standardUserDriverWillFinishUpdateSession() {}

// MARK: - UNUserNotificationCenter Delegate

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

if url.host == "settings" {
showSettings()
return
}
if url.host == "about" {
NSApp.orderFrontStandardAboutPanel(nil)
return
}

show()

if url.host == "navigate",
Expand Down Expand Up @@ -258,4 +272,43 @@ class AppDelegate: NSObject, NSApplicationDelegate,
}
}
}

// MARK: - Activation Policy: Only Settings Visibility Controls It

private func showSettings() {
// Behave like a normal app while Settings is open
NSApp.setActivationPolicy(.regular)
settingsWindowController.show()
NSApp.activate(ignoringOtherApps: true)
settingsWindowController.window?.delegate = self
}

// Revert to accessory when Settings window closes
func windowWillClose(_ notification: Notification) {
guard let win = notification.object as? NSWindow,
win == settingsWindowController.window
else { return }
NSApp.setActivationPolicy(.accessory)
}

private func requestNotificationsAuthorizationIfNeeded(
completion: @escaping (Bool) -> Void
) {
UNUserNotificationCenter.current().getNotificationSettings { settings in
switch settings.authorizationStatus {
case .notDetermined:
UNUserNotificationCenter.current().requestAuthorization(options: [
.alert, .badge, .sound,
]) { granted, _ in
DispatchQueue.main.async { completion(granted) }
}
case .authorized, .provisional, .ephemeral:
DispatchQueue.main.async { completion(true) }
case .denied:
DispatchQueue.main.async { completion(false) }
@unknown default:
DispatchQueue.main.async { completion(false) }
}
}
}
}
2 changes: 1 addition & 1 deletion Leader Key/MainMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class MainMenu: NSMenu {
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: ""),
.separator(),
NSMenuItem(
title: "Preferences...", action: #selector(AppDelegate.settingsMenuItemActionHandler(_:)),
title: "Settings…", action: #selector(AppDelegate.settingsMenuItemActionHandler(_:)),
keyEquivalent: ","),
.separator(),
NSMenuItem(
Expand Down
48 changes: 18 additions & 30 deletions Leader Key/Settings/GeneralPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,22 @@ struct GeneralPane: View {
@EnvironmentObject private var config: UserConfig
@Default(.configDir) var configDir
@Default(.theme) var theme
@State private var expandedGroups = Set<[Int]>()

var body: some View {
Settings.Container(contentWidth: contentWidth) {
Settings.Section(
title: "Config", bottomDivider: true, verticalAlignment: .top
) {
VStack(alignment: .leading, spacing: 8) {
VStack {
ConfigEditorView(group: $config.root, expandedGroups: $expandedGroups)
.frame(height: 500)
// Probably horrible for accessibility but improves performance a ton
.focusable(false)
}
.padding(8)
.overlay(
RoundedRectangle(cornerRadius: 12)
.inset(by: 1)
.stroke(Color.primary, lineWidth: 1)
.opacity(0.1)
)
// AppKit-backed editor for maximum smoothness
ConfigOutlineEditorView(root: $config.root)
.frame(height: 500)
.overlay(
RoundedRectangle(cornerRadius: 12)
.inset(by: 1)
.stroke(Color.primary, lineWidth: 1)
.opacity(0.1)
)

HStack {
// Left-aligned buttons
Expand All @@ -48,22 +43,25 @@ struct GeneralPane: View {
// Right-aligned buttons
HStack(spacing: 8) {
Button(action: {
withAnimation(.easeOut(duration: 0.1)) {
expandAllGroups(in: config.root, parentPath: [])
}
NotificationCenter.default.post(name: .lkExpandAll, object: nil)
}) {
Image(systemName: "chevron.down")
Text("Expand all")
}

Button(action: {
withAnimation(.easeOut(duration: 0.1)) {
expandedGroups.removeAll()
}
NotificationCenter.default.post(name: .lkCollapseAll, object: nil)
}) {
Image(systemName: "chevron.right")
Text("Collapse all")
}

Button(action: {
NotificationCenter.default.post(name: .lkSortAZ, object: nil)
}) {
Image(systemName: "arrow.up.arrow.down")
Text("Sort A → Z")
}
}
}
}
Expand All @@ -86,16 +84,6 @@ struct GeneralPane: View {
}
}
}

private func expandAllGroups(in group: Group, parentPath: [Int]) {
for (index, item) in group.actions.enumerated() {
let currentPath = parentPath + [index]
if case .group(let subgroup) = item {
expandedGroups.insert(currentPath)
expandAllGroups(in: subgroup, parentPath: currentPath)
}
}
}
}

struct GeneralPane_Previews: PreviewProvider {
Expand Down
Loading