This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is a native macOS app with no Makefile or package manager. All builds go through Xcode:
- Build: Use the
BuildProjectMCP tool, or openPadIO.xcodeprojand press ⌘B - Run: ⌘R in Xcode — launches as a menu bar app (no main window)
- Validate code quickly: Use
XcodeRefreshCodeIssuesInFilefor fast compiler feedback without a full build - Test snippets: Use
ExecuteSnippetto run code in the context of a specific file
There are no automated tests. The app requires Accessibility permission at runtime to emit synthetic events.
The live config file is ~/.config/padio/config.json — it hot-reloads automatically when saved. A sample config is at config.json in the repo root.
PadIO is a menu bar daemon that maps game controller inputs to synthetic macOS events. It has no main window — the entire UI is a menu bar dropdown (MenuBarExtra).
PadIOApp.swift creates a single ControllerManager as a @StateObject. All sub-components live inside ControllerManager and are injected into SwiftUI as environment objects.
GCController (GameController framework)
↓ [60Hz polling timer in ControllerManager]
pollControllers()
├─ Edge-detect button presses → handleMappedButton()
│ ├─ Overlay priority: helpOverlay → modePicker → customMenu → menu button
│ ├─ MappingResolver.resolve(heldButtons:) — applies cascade:
│ │ 1–3. combo keys (modifier+button) through global → profile → mode
│ │ 4–6. plain keys through global → profile → mode
│ └─ executeAction() → InputHandler / overlays / HapticController
└─ pollAxes() — continuous axis → mouse/scroll emission (skipped when overlay visible)
| File | Role |
|---|---|
ControllerManager.swift |
Central orchestrator — owns all sub-components, drives the 60Hz loop, resolves and executes actions |
HUDZoom.swift |
Shared hud_zoom scaling wrapper for every overlay + HUDPanelFitter (layout → size → position) |
MappingResolver.swift |
Pure translation layer — config → Action enum; contains all key name and modifier mappings |
MappingConfig.swift |
Codable config types + ConfigLoader (hot-reload via DispatchSource) |
InputHandler.swift |
Low-level CGEvent emission — keystrokes, text injection, mouse, scroll, media keys, input source cycling |
HapticController.swift |
CHHapticEngine management per controller + HapticEventObserver for system beep / notification triggers |
AppObserver.swift |
Tracks frontmost app bundle ID via NSWorkspace; publishes changes to trigger profile re-resolution |
ButtonIdentifier.swift |
ButtonID and AxisID enums; maps GCControllerElement references to canonical names |
ContentView.swift |
Menu bar dropdown UI — status display, permission grant, reload/quit |
*Overlay.swift files |
Floating NSPanel HUDs, each with a SwiftUI view + @Observable view model + controller class |
CustomMenuWheelView.swift |
Circular ("donut") presentation for custom menus, selected by menu_style / per-menu style |
Profile is selected by matching the frontmost app's bundle ID against profile.apps[], falling back to the "default" profile. Within a profile, bindings resolve in this priority order (highest wins):
- Combo key (
modifier+button) in active mode bindings - Combo key in
profile.global - Combo key in top-level
config.global - Plain key in active mode bindings (
profile.modes[activeModeString]) - Plain key in
profile.global - Plain key in top-level
config.global(cross-profile defaults)
Combo keys use the syntax "<modifier>+<button>" (e.g., "X+dpad_up") in any bindings dictionary. When multiple buttons are held, ButtonID.allCases order determines which modifier is tried first.
Mode state is stored in ControllerManager.profileModes: [profileName: modeName].
While an overlay is visible, input is consumed before reaching the mapping pipeline:
HelpController— blocks all input while visibleModePickerController— blocks input while visibleCustomMenuController— blocks input while visiblemenubutton always opens Help (checked before any mapping)
Axis-to-pointer emission (pollAxes) is suppressed while any overlay is visible. The raw sticks are still read in that branch and forwarded to CustomMenuController.handleStick, which is how a wheel-style menu is aimed; it is a no-op for every other overlay and for the list style.
Action is an enum in MappingResolver.swift. buildAction(from: ActionConfig) converts JSON config into Action values. Adding a new action type requires:
- New
casein theActionenum - New
caseinbuildAction()switch - New
caseindescribe()switch - New
caseinControllerManager.executeAction()switch
HapticController caches CHHapticEngine instances keyed by (ObjectIdentifier(controller), GCHapticsLocality). Engines are created via GCDeviceHaptics.createEngine(withLocality:) — not CHHapticEngine() directly. HapticEventObserver listens to DistributedNotificationCenter for system beep (com.apple.sound.alert.played) and notification (com.apple.usernotifications.notification-posted) events.
When making changes to config format, action types, button names, or any user-facing behavior, always update:
README.md— if the change affects the quick-start or feature listdocs/— the relevant MkDocs documentation page(s)config.json— the sample config in the repo root, if applicable
The docs site is built with MkDocs Material (mkdocs.yml). Preview locally with uv run --with mkdocs-material mkdocs serve.
- All UI and state manipulation is
@MainActor. UseMainActor.assumeIsolated { }inside completion handlers (e.g.NSAnimationContext,DispatchWorkItem) that need access to main-actor state. Combineis used only for reactive bindings inControllerManager.init()(config reload, frontmost app changes). New async work should use Swift async/await, not Combine.- Overlay controllers follow the pattern:
@Observableview model + SwiftUI view +@MainActorcontroller class owning anNSPanel. AxisIDraw values ("left_stick","right_stick","dpad") are used as binding keys in the config alongsideButtonIDraw values — they share the same bindings dictionary.