Back to .md Directory

Keypunch — Specification

Defines the architecture, data model, UI components, keyboard navigation, and testing strategy for a macOS menu bar app that registers global keyboard shortcuts to launch applications.

May 2, 2026
0 downloads
0 views
ai
View source

What this file does

Defines the architecture, data model, UI components, keyboard navigation, and testing strategy for a macOS menu bar app that registers global keyboard shortcuts to launch applications.

When to use it

  • Building a macOS menu bar app with SwiftUI and global hotkeys
  • Implementing keyboard navigation and focus management in a SwiftUI settings panel
  • Designing a testable architecture with protocol abstractions and dependency injection
  • Creating a shortcut recording UI with conflict detection and persistence

Assumes this stack

Swift 5.0SwiftUImacOS 15.5+Xcode 16+KeyboardShortcuts librarySwift Testing / XCTest

Keypunch — Specification

Japanese version: SPEC.ja.md

Overview

Keypunch is a macOS menu bar application that registers global keyboard shortcuts to launch applications. It runs without a Dock icon — all interactions happen through a menu bar icon and a standard settings window.

System Requirements

ItemRequirement
OSmacOS 15.5+
Xcode16+
Swift5.0

Architecture

Tech Stack

LayerTechnology
UI FrameworkSwiftUI (standard NSWindow)
Window ManagementNSWindow (titled, closable, miniaturizable)
Global HotkeysKeyboardShortcuts v2.4.0
Shortcut RecordingCustom ShortcutCaptureView (plain NSView)
State Management@Observable (Swift Observation)
Data PersistenceUserDefaults (JSON encoding)
App LaunchingNSWorkspace (via AppLaunching protocol)
Login ItemSMAppService (via LoginItemManaging protocol)
Shortcut RegistrationKeyboardShortcuts (via ShortcutRegistering protocol)

App Configuration

ItemValue
Bundle Identifiercom.mkusaka.Keypunch
LSUIElementYES (hidden from Dock)
Menu Bar IconSF Symbols keyboard

File Structure

Keypunch/
├── KeypunchApp.swift                # Entry point, AppDelegate, test mode control
├── FloatingWidgetController.swift   # Menu bar, standard NSWindow management
├── Models/
│   └── AppShortcut.swift            # Shortcut data model
├── ShortcutStore.swift              # State management, persistence (delegates to services)
├── Protocols/
│   ├── AppLaunching.swift           # NSWorkspace abstraction for app launching
│   ├── BundleProviding.swift        # Bundle.main abstraction
│   ├── LoginItemManaging.swift      # SMAppService abstraction
│   └── ShortcutRegistering.swift    # KeyboardShortcuts static API abstraction
├── Services/
│   ├── AppLaunchService.swift       # App launching + self-activation logic
│   ├── LoginItemService.swift       # Login item toggle logic
│   └── ShortcutRegistrationService.swift  # Shortcut register/unregister/reset
├── Views/
│   ├── FloatingPanelView.swift      # Settings panel (SettingsPanelView)
│   ├── EditCardView.swift           # Per-row edit mode card
│   ├── EditCardBadges.swift         # SetBadgeButton, NotSetBadgeButton, EditShortcutButton
│   ├── CardActionButton.swift       # Reusable action button (unset, delete, cancel)
│   ├── CompactRowView.swift         # Compact row for non-edit mode
│   ├── EditPencilButton.swift       # Pencil edit button component
│   ├── RecordingBadgeView.swift     # Recording mode badge with ShortcutCaptureView
│   ├── DeleteConfirmationDialog.swift  # Delete confirmation overlay
│   ├── DuplicateAlertDialog.swift   # Duplicate app alert overlay
│   ├── AddAppButtonView.swift       # Add App button with NSOpenPanel
│   ├── PanelFocus.swift             # PanelFocus enum for focus management
│   └── ShortcutCaptureView.swift    # NSView for keyboard shortcut capture
└── Keypunch.entitlements            # (empty — no sandbox)

KeypunchTests/
└── KeypunchTests.swift              # Unit tests (Swift Testing)

KeypunchUITests/
├── KeypunchUITests.swift            # UI tests (XCTest)
└── KeypunchUITestsLaunchTests.swift # Launch tests

Data Model

AppShortcut

A struct representing a single application shortcut configuration.

struct AppShortcut: Identifiable, Codable, Hashable
PropertyTypeDefaultDescription
idUUIDAuto-generatedUnique identifier
nameStringDisplay name (derived from app file name)
bundleIdentifierString?macOS bundle ID (e.g., com.apple.calculator)
appPathStringFull file system path to the application
shortcutNameString"appShortcut_\(id)"Unique name for KeyboardShortcuts library registration
isEnabledBooltrueWhether the shortcut is active (key binding preserved when disabled)

Computed Properties:

PropertyTypeDescription
keyboardShortcutNameKeyboardShortcuts.NameName object for library integration
appURLURLFile URL generated from appPath
appDirectoryStringParent directory path (e.g., /System/Applications)

Codable Compatibility:

  • isEnabled uses decodeIfPresent with true fallback for backward compatibility with older data that lacks this field.

Constraints:

  • id is auto-generated at creation, guaranteeing uniqueness
  • shortcutName is also auto-generated based on id, guaranteeing uniqueness
  • bundleIdentifier allows nil (for apps without a bundle ID)

State Management

ShortcutStore

The @Observable class responsible for managing all shortcuts across the application. Uses dependency injection via protocol abstractions for testability.

@MainActor
@Observable
final class ShortcutStore

Dependencies (injected via init with defaults):

  • defaults: UserDefaults — persistence store
  • workspace: AppLaunching — app launching (default: NSWorkspace.shared)
  • registrar: ShortcutRegistering — shortcut registration (default: KeyboardShortcutsRegistrar())
  • mainBundle: BundleProviding — bundle identity (default: Bundle.main)

Internal Services:

  • AppLaunchService — handles app launching and self-activation detection
  • ShortcutRegistrationService — handles shortcut register/unregister/reset

Persistence

ItemValue
StorageUserDefaults
Key"savedAppShortcuts"
FormatJSON (JSONEncoder / JSONDecoder)
Data[AppShortcut] array
LoadingDecoded from UserDefaults in init()
Corrupt DataSilently loads empty array on decode failure

Note: The actual keyboard shortcut key bindings are persisted independently by the KeyboardShortcuts library in its own UserDefaults entries. ShortcutStore only saves app metadata.

Public Properties

PropertyTypeDescription
shortcuts[AppShortcut]All registered shortcuts (read-only)
shortcutKeysVersionIntIncremented on key binding changes, used to force SwiftUI refresh

Public Methods

MethodDescription
addShortcut(_:)Adds a shortcut, registers its handler, and persists to disk
removeShortcut(_:)Removes a shortcut, resets its key binding, and persists
removeShortcuts(at:)Batch-removes shortcuts by IndexSet
updateShortcut(_:)Updates an existing shortcut by ID. Resets old key binding if shortcutName changed
toggleEnabled(for:)Toggles isEnabled state. When disabled, handler is emptied but key binding is preserved
unsetShortcut(for:)Resets key binding via KeyboardShortcuts.reset(). App entry remains. Increments shortcutKeysVersion
containsApp(path:)Checks if an app at the given path is already registered
containsApp(bundleIdentifier:)Checks if an app with the given bundle ID is already registered
isShortcutConflicting(_:excluding:)Checks if a shortcut key combo conflicts with another registered shortcut
addShortcutFromURL(_:)Adds from URL with duplicate detection. Returns .success(AppShortcut) or .duplicate(String)
launchApp(for:)Launches the target application

App Launch Logic

launchApp(for:) resolves the application in the following priority order:

  1. If bundleIdentifier is non-nil and resolvable via NSWorkspace.shared.urlForApplication(withBundleIdentifier:) → launch using that URL
  2. Fallback: convert appPath to a URL and launch

Both paths use NSWorkspace.shared.openApplication(at:configuration:).

Handler Registration

  • registerHandler(for:) checks isEnabled before setting up the callback
  • When disabled, an empty handler is registered (preserving the key binding)
  • On init(), handlers are registered for all loaded shortcuts in bulk
  • shortcutKeysVersion is incremented via NotificationCenter observation of KeyboardShortcuts_shortcutByNameDidChange

UI Components

1. Menu Bar (Status Item)

The primary entry point for app control via NSStatusItem with a keyboard icon.

Menu Items:

  • "Show Keypunch" → opens the settings window
  • Separator
  • "Start at Login" → toggles login item (checkmark when enabled, via NSMenuDelegate)
  • Separator
  • "Quit" (⌘Q) → terminates app

2. Settings Window

A standard macOS NSWindow for managing shortcut configurations.

Size: 380 × 616 pt Style: .titled, .closable, .miniaturizable (standard traffic light buttons) Title: "Keypunch" Accessibility ID: keypunch-panel

Panel Structure

┌──────────────────────────────────────┐
│ ● ● ●  Keypunch                      │  ← standard title bar
│──────────────────────────────────────│
│ [icon] Calculator      ⌘⇧C    [✎]  │  ← compact row (LaunchRow)
│        /System/Applications          │
│ [icon] TextEdit        Not set [✎]  │
│        /System/Applications          │
│                                      │
│         [+ Add App]                  │  ← add button
└──────────────────────────────────────┘

Compact Row (LaunchRow)

Each registered app is shown as a compact row.

ElementSizeDescription
App icon28×28NSWorkspace.shared.icon(forFile:), rounded corners (7pt)
App name13pt, medium weight. Semibold on hover
App directory10pt, secondary color, middle truncation
Shortcut badge3-state display (see below)
Edit button22×22Pencil icon, opens per-row edit mode

Shortcut Badge (3 states):

StateDisplayBadge Color
Set & ActiveKey combo (e.g., ⌘⇧C)Accent color, background accent @ 15%
DisabledKey combo with strikethroughSecondary color
Not set"Not set" textTertiary color

Hover Effect: Row background changes to accent-tinted @ 8%, border accent @ 20%.

Click: Launches the target application via store.launchApp(for:).

Edit Button: accessibilityIdentifier("edit-shortcut"). Transitions to EditCard for that row with 0.15s opacity animation.

Edit Card (Expanded Per-Row Edit Mode)

When the pencil button is clicked, the compact row expands into an edit card. Dimensions are unified with the compact row for consistent row height.

ElementSizeDescription
App icon28×28Rounded corners (7pt)
App name13pt, semibold
App directory10pt, secondary color
Shortcut badge areaheight 22, r63 states: not set, recording, set
Unset shortcut (↺)22×22, r6Resets key binding (only shown when shortcut is set)
Delete app (🗑)22×22, r6Opens delete confirmation overlay
Cancel button (X)22×22, r6Exits edit mode

Row padding: horizontal 10, vertical 8. Corner radius: 12.

Button Layout: [icon] [name] [badge] [✎] [↺] [🗑] [×] — all action buttons are inline, no dropdown/popover. Edit button (✎) is a standalone button between badge and unset.

Shortcut Badge Area (3 states):

  1. Not Set: "Not set" text + pencil icon. Click to start recording. accessibilityIdentifier("not-set-badge")
  2. Recording: Amber dot (#FFB547) + "Record" text + X cancel. Background #FFB547 @ 12.5%, border #FFB547 @ 25%. Custom ShortcutCaptureView captures keyboard input.
  3. Set: Key combo text — toggle-only (click/Enter = enable/disable). No embedded pencil icon. accessibilityIdentifier("shortcut-badge")

Edit Button (standalone): accessibilityIdentifier("record-shortcut"). Pencil icon between badge and unset button. Only shown when a shortcut is set and not recording. Click/Enter starts re-recording.

Tab Loop (Edit Mode): Tab and Shift+Tab are trapped within the edit card via onKeyPress. Focus cycles through card elements without escaping to other rows or the Add App button. Focus order: shortcutBadgeshortcutEditButton (✎, if shortcut set) → dangerButton (↺, if shortcut set) → deleteButton (🗑) → cancelEdit (×) → wraps to shortcutBadge.

Cancel Edit: accessibilityIdentifier("cancel-edit"). Returns to compact row.

Unset Shortcut: accessibilityIdentifier("unset-shortcut"). Only shown when a key binding exists. Resets key binding, preserves app entry. Focus returns to unset button position after action.

Delete App: accessibilityIdentifier("delete-app"). Opens delete confirmation overlay.

Delete Confirmation Overlay

A modal overlay within the panel showing:

  • Trash icon in red circle
  • "Remove [AppName]?" title
  • Warning text about irreversibility
  • Cancel and Remove buttons
  • Remove button uses .borderedProminent style with destructive tint
  • No default focus — buttons have no automatic keyboard focus on display

Duplicate Application Dialog

A modal overlay (same style as delete confirmation) shown when attempting to add an already-registered app:

  • Warning triangle icon in orange circle
  • "Duplicate Application" title
  • "[name] has already been added." message
  • OK button (.borderedProminent style) to dismiss
  • Background interactions disabled while shown
  • Esc key also dismisses the dialog

Add App Button

  • Label: "+ Add App"
  • Style: Full-width button with dashed border
  • .contentShape(Rectangle()) for full hit area
  • Opens NSOpenPanel filtered to .application
  • Duplicate detection by path and bundle ID
  • Focus moves to the newly added app row after a successful selection
  • Shows duplicate dialog on duplicate attempt

Keyboard Navigation

Keypunch supports keyboard navigation within the standard settings window.

Settings Window (SettingsPanelView)

Focus Management: @FocusState with PanelFocus enum controlling focus across all UI elements.

Focus Targets (PanelFocus enum):

CaseDescription
.row(UUID)Compact row — Enter launches app
.editButton(UUID)Edit (pencil) button on compact row — Enter enters edit mode
.addAppAdd App button — Enter opens file dialog
.shortcutBadge(UUID)Shortcut badge in edit mode — Enter toggles enable/disable (when set) or starts recording (when not set)
.shortcutEditButton(UUID)Standalone pencil button — Enter starts re-recording (only shown when shortcut is set)
.cancelEdit(UUID)Cancel (×) button in edit mode — Enter exits edit
.dangerButton(UUID)Unset (↺) button in edit mode — Enter unsets shortcut
.deleteButton(UUID)Delete (🗑) button in edit mode — Enter opens delete dialog

Tab Order (non-edit mode): Tab/Shift+Tab cycles through all focusable elements: .row(app1).editButton(app1).row(app2).editButton(app2) → … → .addApp → wraps back to .row(app1).

Tab Order (edit mode): Tab/Shift+Tab loops within the edit card. shortcutBadgeshortcutEditButton (✎, if shortcut set) → dangerButton (↺, if shortcut set) → deleteButton (🗑) → cancelEdit (×) → wraps back to shortcutBadge. Focus never escapes to other rows or Add App button while in edit mode.

Arrow Key Navigation (Up/Down): Up/Down arrows move between app rows only (skipping edit buttons, wrapping). When no element is focused, Down arrow focuses the first row (or Add App if list is empty), Up arrow focuses Add App. Disabled in edit mode.

Arrow Key Navigation (Left/Right): In non-edit mode, Right arrow moves focus from .row(id).editButton(id), Left arrow moves from .editButton(id).row(id). No effect at boundaries. In edit mode, Left/Right arrows cycle through edit card elements (same order as Tab loop, wrapping).

Esc Handling (layered .onExitCommand):

  1. Duplicate dialog showing → dismiss it
  2. Delete confirmation showing → dismiss, focus delete button
  3. Recording shortcut → cancel recording
  4. Edit mode → exit edit mode, focus the compact row
  5. Non-edit mode with focus → clear focus (return to initial unfocused state)

Dialog Behavior:

  • While delete or duplicate dialog is showing, background panel content is .disabled(true) to prevent Tab focus leaking
  • Delete dialog cancel → focus returns to delete button in edit card
  • Esc from delete dialog → same behavior as cancel

Window Management

FloatingWidgetController

@MainActor controller that manages the menu bar and settings window.

Components

ComponentClassSizePurpose
Settings WindowNSWindow380×616Main shortcut configuration window
Status ItemNSStatusItemSquareMenu bar icon with dropdown menu

Show/Hide Logic

EventAction
"Show Keypunch" clickedmakeKeyAndOrderFront + NSApp.activate()
Window close button clickedStandard window close behavior (isReleasedWhenClosed = false)
App reopen (Dock click)Shows settings window
Test mode launchAuto-shows settings window

Keyboard Shortcut Recording

ShortcutCaptureView

A plain NSView subclass (not NSSearchField-based) to avoid ViewBridge disconnection errors in floating panels.

Behavior:

  1. View becomes first responder via window.makeFirstResponder(view)
  2. User presses modifier + key → KeyboardShortcuts.setShortcut() called
  3. Escape → cancels recording
  4. Resign first responder → cancels recording

Conflict Detection: After setting a shortcut, store.isShortcutConflicting() checks all other registered shortcuts. If conflict detected, the shortcut is reset.


Application Lifecycle

KeypunchApp (Entry Point)

@main struct KeypunchApp: App
  • Creates ShortcutStore and shares it via static properties
  • AppDelegate.applicationDidFinishLaunching creates FloatingWidgetController
  • Guard: skips controller setup when running under XCTestCase
  • applicationShouldHandleReopen shows settings window when no visible windows

Login Item Support

  • Uses SMAppService.mainApp via LoginItemManaging protocol and LoginItemService
  • Toggle via menu bar "Start at Login" item
  • Checkmark shown when enabled (via NSMenuDelegate.menuNeedsUpdate)

Test Mode

Mechanism to control app behavior during CI and test execution.

Command Line Arguments

ArgumentUserDefaults ResetSeed DataWindow Auto-Show
-resetForTestingYesYes (if env var present)Yes
-seedOnlyYesYes (if env var present)No
(none)NoNoNo

Environment Variables

VariableTypeDescription
SEED_SHORTCUTSJSON stringSeed data for testing. An array of AppShortcut objects in JSON format

Seed Data Format:

[
  {
    "id": "UUID-string",
    "name": "Calculator",
    "bundleIdentifier": "com.apple.calculator",
    "appPath": "/System/Applications/Calculator.app",
    "shortcutName": "test_UUID-string"
  }
]

Test Mode Effects

FeatureNormal ModeTest Mode (-resetForTesting)
Window displayManual via menu barAuto-shown on launch
Panel displayAll shortcuts shownAll shortcuts shown
UserDefaultsNormal operationReset on launch

Testing

Unit Tests (Swift Testing)

Framework: @Test, #expect (Swift Testing) Test UserDefaults: isolated per test with unique suiteName

AppShortcutTests (12 tests)

TestVerified Behavior
initWithDefaultsDefault initialization sets correct properties
initWithCustomShortcutNameCustom shortcutName is preserved
initWithNilBundleIdentifiernil bundleIdentifier is accepted
isEnabledDefaultsToTrueisEnabled defaults to true
isEnabledCanBeSetToFalseisEnabled can be set to false
codableRoundTripSingle shortcut JSON encode/decode is accurate
codableBackwardCompatibilityOld JSON without isEnabled field defaults to true
codableRoundTripArrayArray JSON encode/decode is accurate
hashableConformanceShortcuts with same ID are equal and hash identically
uniqueIdsOnCreationEach new instance gets a unique ID and shortcutName
appDirectoryComputedappDirectory returns parent directory path
appDirectoryForNestedPathappDirectory works for deeply nested paths

ShortcutStoreTests (19 tests, serialized)

TestVerified Behavior
addShortcutAdding increments count and stores correctly
removeShortcutRemoving empties the array
removeShortcutsAtOffsetsBatch removal by IndexSet
updateShortcutExisting shortcut is updated
updateNonexistentShortcutIsNoopUpdating non-existent ID is a no-op
persistenceAcrossInstancesData is restored after store re-creation
emptyStoreOnFreshDefaultsFresh UserDefaults yields empty store
containsAppByPathDuplicate detection by path
containsAppByBundleIdentifierDuplicate detection by bundle ID
toggleEnabledToggle flips isEnabled state and back
toggleEnabledPersistsToggled state persists across store instances
unsetShortcutKeepsAppEntryUnset removes key binding but keeps app entry
unsetShortcutIncrementsVersionshortcutKeysVersion increments after unset
containsAppByBundleIdentifierWithNilBundleIDsnil bundle IDs don't cause false positives
addShortcutFromURLSuccessAdding from valid URL extracts name, path, bundle ID
addShortcutFromURLDuplicateByPathDuplicate detection by path via URL
addShortcutFromURLDuplicateByBundleIDDuplicate detection by bundle ID via URL
corruptDataLoadsEmptyCorrupt UserDefaults data results in empty store
toggleEnabledNonexistentIsNoopToggle on nonexistent shortcut is no-op

ShortcutStoreBehaviorTests (10 tests, serialized)

Uses mock implementations of AppLaunching, ShortcutRegistering, and BundleProviding protocols.

TestVerified Behavior
launchAppResolvesByBundleIDLaunch resolves app by bundle ID when available
launchAppFallsBackToAppPathFalls back to appPath when bundle ID not resolvable
launchAppFallsBackWhenNoBundleIDFalls back to appPath when bundleIdentifier is nil
launchAppSelfActivationSelf-activation callback fires when launching own bundle
removeShortcutResetsBindingRemoving shortcut calls reset on registrar
toggleDisabledRegistersNoopHandlerDisabling registers an empty handler (preserves binding)
conflictDetectionFindsConflictDetects conflicting shortcut across different names
conflictDetectionNoConflictWhenExcludedNo conflict when excluding the same name
conflictDetectionNoConflictWhenDifferentNo conflict for different key combinations
unsetShortcutCallsResetUnsetting calls reset on registrar

UI Tests (XCTest)

Framework: XCTest / XCUITest

Test Helpers (KeypunchPage)

MethodDescription
launchClean()Launches with -resetForTesting flag
launchWithSeededShortcuts(_:)Launches with seed data + test mode
launchWithSeededShortcutsNoTestMode(_:)Launches with seed data + normal mode (-seedOnly)
makeSeedShortcut(name:bundleID:appPath:)Generates a seed data dictionary
waitForWindow()Waits for the settings window (keypunch-panel) to appear
openEditMode()Waits for window and clicks edit button on first row
clickRecordShortcut()Finds and clicks record-shortcut or not-set-badge element

Window Tests (1 test)

TestVerified Behavior
testWindowAppearsInTestModeSettings window appears automatically in test mode

Panel Content Tests (5 tests)

TestVerified Behavior
testEmptyStatePanelContentsEmpty state shows "No shortcuts configured"
testSeededShortcutAppearsInPanelSeeded shortcut appears in panel
testMultipleSeededShortcutsAppearInPanelMultiple shortcuts appear
testPanelShowsAppIconAndBadgeApp icon and "Not set" badge displayed
testPanelShowsAddAppButton"Add App" button exists

Edit Mode Tests (5 tests)

TestVerified Behavior
testEditButtonExistsOnRowEdit (pencil) button exists on shortcut row
testEditModeShowsSeededShortcutShortcut appears in edit mode
testEditModeShowsAppDirectoryAndBadgeApp directory and "Not set" badge in edit card
testDeleteButtonExistsInEditModeDelete button exists in edit mode
testCancelEditExitsEditModeCancel edit returns to compact mode

Compact Row Tests (2 tests)

TestVerified Behavior
testCompactRowShowsAppDirectoryCompact row shows app directory path
testMultipleShortcutsShowSeparateEditButtonsEach row has its own edit button

Edit Mode Exclusivity Tests (2 tests)

TestVerified Behavior
testEditModeIsExclusiveOnly one row can be in edit mode at a time
testEditModeSwitchCancelsRecordingSwitching edit mode to another row cancels recording

App Launch Tests (2 tests)

TestVerified Behavior
testPanelLaunchesAppClicking app name launches TextEdit
testEditButtonClickEntersEditModeClicking edit button enters edit mode

Delete Confirmation Tests (3 tests)

TestVerified Behavior
testDeleteConfirmationModalAppearsDelete confirmation shows "Remove Calculator?"
testDeleteConfirmationCancelKeepsShortcutCancel keeps the shortcut entry
testDeleteConfirmationRemoveDeletesShortcutRemove deletes the shortcut and shows empty state

Recording Mode Tests (2 tests)

TestVerified Behavior
testRecordingModeShowsRecordBadge"Record" badge appears when recording
testRecordingCancelButtonExitsRecordingCancel exits recording mode, shows "Not set"

Add App Tests (3 tests)

TestVerified Behavior
testAddAppButtonOpensFileDialogClicking "Add App" opens NSOpenPanel file dialog
testAddAppViaOpenPanelAdding an app via open panel creates a new row
testAddDuplicateAppShowsAlertAdding a duplicate app shows duplicate alert

Record Shortcut E2E Tests (2 tests)

TestVerified Behavior
testRecordShortcutSetsKeyRecording a shortcut sets the key binding
testRecordShortcutThenUnsetRecording then unsetting clears the key binding

Danger Zone Tests (2 tests)

TestVerified Behavior
testUnsetButtonNotShownWhenNoShortcutSetUnset button hidden when no shortcut is bound
testUnsetShortcutPreservesEditModeUnsetting shortcut keeps edit mode active

Esc Behavior Tests (4 tests)

TestVerified Behavior
testKeyboardEscExitsEditModeBeforeDismissingFirst Esc exits edit mode, window remains visible
testKeyboardEscDismissesDeleteConfirmationEsc dismisses delete confirmation, window remains
testEscDuringRecordingStaysInEditModeEsc during recording cancels recording but stays in edit mode
testEscFromRemoveDialogKeepsEditModeEsc from remove dialog keeps edit mode
testEscClearsFocusInNonEditModeEsc clears focus in non-edit mode, returning to initial unfocused state

Keyboard Navigation: Tab (3 tests)

TestVerified Behavior
testKeyboardTabNavigatesBetweenRowsTab navigates through row → editButton → next row, Enter launches app
testTabStopsOnEditButtonBetweenRowsTab stops on edit button after row, Enter enters edit mode
testKeyboardShiftTabNavigatesBackwardShift-Tab navigates backward, Enter launches first app

Keyboard Navigation: Arrow Keys (12 tests)

TestVerified Behavior
testDownArrowNavigatesBetweenAppsDown arrow moves between app rows
testUpArrowNavigatesBetweenAppsUp arrow moves between app rows
testDownArrowWrapsToAddAppDown arrow wraps from last row to Add App
testUpArrowWrapsFromFirstToAddAppUp arrow wraps from first row to Add App
testRightArrowMovesToEditButtonRight arrow from row moves to edit button
testLeftArrowMovesBackToRowLeft arrow from edit button moves back to row
testRightArrowNoOpOnEditButtonRight arrow on edit button is no-op
testLeftArrowNoOpOnRowLeft arrow on row is no-op
testUpDownArrowDisabledInEditModeUp/Down arrows are disabled in edit mode
testDownArrowFromNoFocusFocusesFirstRowDown arrow from no focus focuses first row
testUpArrowFromNoFocusFocusesAddAppUp arrow from no focus focuses Add App
testDownArrowFromNoFocusEmptyListFocusesAddAppDown arrow with empty list focuses Add App

Tab Navigation: Edit Mode (12 tests)

TestVerified Behavior
testTabOrderEditModeNoShortcutToCancelEditTab from badge → delete → cancel when no shortcut set
testTabOrderEditModeNoShortcutToDeleteButtonTab from badge → delete button when no shortcut set
testTabOrderEditModeWithShortcutToCancelEditTab reaches cancel button when shortcut is set
testTabOrderEditModeWithShortcutToUnsetButtonTab reaches unset button when shortcut is set
testShiftTabInEditModeShift+Tab navigates backward within edit card
testFocusRestoredAfterRecordingCancelFocus returns to badge after recording cancel
testFocusRestoredAfterRecordingCancelWithTwoAppsFocus returns to badge after cancel with multiple apps
testTabLoopsWithinEditCardTab loops within card, never escapes to other rows
testToggleShortcutEnabledViaKeyboardEnter on set badge toggles enable/disable (doesn't record)
testShiftTabLoopsWithinEditCardWithTwoAppsShift+Tab wraps within card with multiple apps
testEditButtonIsStandaloneWithShortcutSetEdit button is standalone, Enter starts recording
testTabOrderWithShortcutSetFull 5-element Tab order: badge → edit → unset → delete → cancel

Scroll & Many Apps Tests (2 tests)

TestVerified Behavior
testManyAppsScrollablePanel scrolls when many apps are added
testAutoScrollWithArrowKeysArrow key navigation auto-scrolls to focused row

Launch Tests (1 test)

TestVerified Behavior
testLaunchApp launches and captures a screenshot

Test Count Summary

CategoryCount
Unit: AppShortcutTests12
Unit: ShortcutStoreTests19
Unit: ShortcutStoreBehaviorTests10
UI: Window1
UI: Panel Content5
UI: Edit Mode5
UI: Compact Row2
UI: Edit Mode Exclusivity2
UI: App Launch2
UI: Delete Confirmation3
UI: Recording Mode2
UI: Add App3
UI: Record Shortcut E2E2
UI: Danger Zone2
UI: Esc Behavior5
UI: Keyboard Navigation: Tab3
UI: Keyboard Navigation: Arrow Keys12
UI: Tab Navigation: Edit Mode12
UI: Scroll & Many Apps2
UI: Launch1
Total105

CI/CD

GitHub Actions Workflow

File: .github/workflows/test.yml Trigger: push, pull_request, and workflow_call (push and pull_request are filtered by paths: Keypunch/**, Keypunch.xcodeproj/**, KeypunchTests/**, KeypunchUITests/**, .github/workflows/test.yml)

JobRunnerTarget
Lintmacos-15SwiftFormat + SwiftLint
Unit Testsmacos-15KeypunchTests
UI Testsmacos-15KeypunchUITests

Actions: actions/checkout is pinned to a commit hash via pinact. release.yml calls this workflow before the signed release job runs.


Dependencies

PackageVersionPurpose
KeyboardShortcuts2.4.0 (>=2.2.2)Global keyboard shortcut registration and management

Known Limitations

  1. ViewBridge Errors: RecorderCocoa (NSSearchField subclass) causes ViewBridge disconnection errors in floating panels. Replaced with custom ShortcutCaptureView (plain NSView).
  2. Zombie Processes: If a zombie process remains after an Xcode debug session, XCUITest's tearDown will fail with a termination error. resilientLaunch() mitigates this.

License

MIT

What's inside

10 major sections covering architecture, data model, state management, UI components, keyboard navigation, window management, shortcut recording, lifecycle, test mode, and testing with 41 unit tests and UI test helpers

Change this for your project

  • Replace com.mkusaka.Keypunch with your own bundle identifier
  • Replace mkusaka/keypunch repository references with your own repo name
  • Replace KeyboardShortcuts library dependency with your own version or alternative

Where it goes

Keep in docs/ or alongside the feature. Agents read it to implement against a defined contract.

Worth borrowing

  • Protocol-based dependency injection for testability (AppLaunching, ShortcutRegistering, etc.)
  • Layered Esc handling with onExitCommand for modal dialogs, recording, and edit mode
  • Tab loop trapped within an edit card to prevent focus escaping to other rows

Related Documents